Catalisa.Building Blocks
Catálogo/Financeiro/Banking

Banking

Alpha

A porta do aplicativo de banco digital, sem regra de dinheiro própria

62
Endpoints
17
Entidades
0
Provedores
Tenant
Escopo
3040
Porta
2026-09
Desde

O aplicativo do seu banco fala com um endereço só. Por trás dele, cada centavo passa pelo mesmo portão que já protege a conta, e nenhuma tela decide sozinha se a selfie é necessária.

Para quem é
  • Instituição que lança aplicativo de banco digital sobre a conta do BaaS da Catalisa
  • Fintech que precisa de sessão por aparelho assinada, não por senha
  • Operação que tem de responder 'não sei se saiu' em vez de pagar duas vezes
Substitui
  • Backend-for-frontend escrito à mão para cada aplicativo
  • Sessão por senha guardada no aparelho
  • Lógica de risco e de limite espalhada pelas telas
O que não é
  • O BaaS — quem fala com o provedor e move dinheiro é o `baas`
  • Catálogo de produtos — isso é o `banking-product-portfolio` (schema `banking_portfolio`)
  • Motor antifraude ou de decisão de crédito
  • Aplicativo; é o servidor que o aplicativo chama
01

Resumo executivo

O banking é o servidor que o aplicativo de banco digital chama. Ele implementa o contrato OrchestrationClient do banking-core (repositório do aplicativo): 61 métodos, uma rota cada. Implementados: 31 métodos — sessão, início, extrato, Pix (com o desfecho pela chave), a leitura dos bloqueios de saldo, o cadastro de aparelho de quem já tem conta (manifesto, capacidades, revínculo por CPF com código no WhatsApp do cadastro, selfie do titular, nonce de atestação, cadastro da chave, lista e revogação de aparelhos) e a abertura de conta com documento e selfie (§7.2). Os outros 21 respondem 501 NOT_IMPLEMENTED, nunca 404 e nunca dado inventado.

O bloco orquestra. Saldo, extrato, chave do aparelho, desafio da operação, reserva por chave e envio moram no baas; nome do titular no people; hash da trilha no audit-trail. O que é do banking é o que é da porta: quem pode entrar, a sessão aberta, o desafio de login, a intenção de Pix com o seu desfecho, a decisão de step-up e os limites do canal.

Três vizinhos com nome parecido:

BlocoO que faz
baasfala com o provedor e move dinheiro
banking-product-portfolio (schema banking_portfolio)catálogo de produtos
banking (schema banking)recebe o aplicativo e chama os outros
02

O problema

negócio

Um aplicativo de banco precisa de um servidor que diga, em cada tela, o que pode e o que não pode. Sem ele, cada aplicativo escreve o seu, e as mesmas falhas voltam:

  • sessão por senha guardada no aparelho, em vez de assinatura da chave do enclave;
  • step-up escolhido pela tela: a tela decide "digital basta" e ninguém confere;
  • "erro de rede" vira nova tentativa num Pix que já saiu — o pagamento em dobro;
  • limite e risco espalhados pelas telas, divergindo a cada versão.
03

Proposta de valor

negócio
  • Um endereço para o aplicativo, com o formato no fio que ele já foi testado contra.
  • Nenhuma regra de dinheiro reimplementada: quem confere a assinatura da operação, queima o desafio, confere a selfie e reserva por chave é o portão do baas.
  • Recusas antes do débito, e "não sei" quando não sabe.
04

Casos de uso reais

negócio

Login por aparelho. O aplicativo pede um desafio (POST /v1/session/challenges), assina o nonce com a chave do enclave e abre a sessão (POST /v1/session). O servidor confere a assinatura contra a chave pública que o baas guarda para aquele aparelho.

Pix de R$ 50 para favorecido novo. A intenção (POST /v1/pix/intents) volta com stepUp: "device_biometric" e o motivo NEW_RECIPIENT informado: abaixo dos limites basta a biometria do aparelho (a assinatura do chip). Decisão do dono (30/09): valor baixo não pede selfie.

Pix de R$ 800 para favorecido novo à noite. Passa dos limites de favorecido novo e de noite (R$ 500 cada, no padrão): stepUp: "selfie", motivos NEW_RECIPIENT e NIGHT. O aplicativo faz a selfie vinculada ao hash da operação e envia — com a assinatura, que é sempre exigida. O banking exige a selfie ao portão do baas (requireBiometric: true), que a confere inteira. A política por valor está em §7.1.

Resposta perdida. O envio sai e a conexão cai. O aplicativo recebe PIX_OUTCOME_UNKNOWN e consulta GET /v1/pix/transfers/by-key/:idempotencyKey em vez de mandar de novo. Enquanto o desfecho não se resolve, nenhuma intenção nova começa (PIX_PENDING_CONFIRMATION).

Aparelho novo de quem já tem conta. Antes do login não há sessão: o escopo vem do host do aplicativo (tenant_hosts). A pessoa informa o CPF (POST /v1/onboarding) e recebe um rascunho bind_device neutro — igual com ou sem conta. O código vai ao WhatsApp do cadastro (POST …/otp, template de autenticação do wpp-business), só quando a conta existe. Conferido o código (POST …/otp/verify), GET /v1/onboarding/:id passa a devolver a conta, o aplicativo abre a selfie amarrada ao hash de cadastro da chave (POST /v1/biometrics/sessions com onboardingId), pede um nonce (POST /v1/devices/attestation-nonce) e cadastra a chave (POST /v1/devices). O primeiro login desse aparelho entra só com a assinatura.

Primeiro login pedindo selfie. openSession respondeu IDENTITY_NOT_PROVEN: o aplicativo abre a selfie com deviceId (POST /v1/biometrics/sessions), sem sessão; a selfie fica presa a esse aparelho, e openSession só a aceita dele.

Saldo bloqueado. A Home traz três números (balance): o que a pessoa tem, o bloqueado e o gastável — este em destaque. GET /v1/holds e GET /v1/holds/:id explicam cada bloqueio em frases ("Reservado para a antecipação nº ANT-0917"), montadas aqui a partir do motivo e do razão do baas; nenhum código do bloco chega à tela. Quando a pessoa tenta mandar mais do que o gastável e cabe no que ela tem, a recusa vem explicada: INSUFFICIENT_SPENDABLE_BALANCE com error.refusal (quanto pediu, quanto está livre, quanto está bloqueado, se nada saiu, se o desafio foi gasto) — antes da revisão, na intenção; e no envio, venha do portão do baas (custódia LEDGER) ou do banco (custódia PROVIDER, decisão do dono de 27/09: o banco recusa, a tela explica).

05

Mercado e diferenciais

negócio

Plataformas de embedded banking (Unit, Swan, Dock) entregam a conta; o servidor do aplicativo, a sessão do usuário final e o step-up continuam sendo do cliente. O banking entrega essa camada já presa ao portão do BaaS — a mesma função de verificação, o mesmo desafio de uso único — em vez de uma segunda implementação que diverge.

06

Modelo de cobrança e ROI

negócio

Precificação em definição. Os direcionadores prováveis são contas ativas no aplicativo e Pix enviados; o ROI está em não escrever e manter um backend por aplicativo.

07

Arquitetura

sequenceDiagram
  participant App as Aplicativo
  participant B as banking
  participant BaaS as baas (portão)
  participant P as people
  participant T as audit-trail
  App->>B: POST /v1/session (assinatura do nonce [+ biometricSessionId])
  B->>BaaS: chaves do aparelho + política
  Note over B: verifyOperationSignature (função do baas) sobre o nonce guardado
  opt aparelho novo (nunca provou identidade nesta conta)
    B->>BaaS: verifyHolderIdentity(selfie)
    Note over BaaS: biometrics.verifyEvidence contra o documento do titular
    Note over B: selfie recente, gasta pelo índice único
  end
  B->>P: nome do titular
  B-->>App: { data: Session }
  App->>B: POST /v1/pix/intents
  B->>BaaS: DICT, política, desafio da operação
  Note over B: step-up e limites decididos aqui
  App->>B: POST /v1/pix/transfers (Idempotency-Key)
  Note over B: conferência do desafio ANTES do baas, reserva por conta
  B->>BaaS: initiatePixTransfer (+ requireBiometric se selfie)
  Note over BaaS: assinatura sobre o payload canônico, queima do desafio, selfie, saldo gastável, reserva, provedor
  B->>T: hash da linha da trilha
  B-->>App: { data: { transaction, trailHash } }

Onde a assinatura é verificada.

  • Login (openSession): neste bloco, em services/session.service.ts, com verifyOperationSignature importada de @baas/utils/canonical-operation — a função do próprio baas, não uma cópia. A chave pública vem do baas (baas_device_keys, pela fachada listDevices); o texto assinado é o nonce que este servidor sorteou e guardou. O baas não tem verificação de presença fora de operação, por isso a de login mora aqui.
  • Selfie do primeiro login (aparelho novo): em services/device-identity.service.ts. A identidade é conferida no baas (HolderAuthorizationService.verifyHolderIdentity, pela fachada verifyHolderIdentity), que chama o bloco biometrics com o documento do titular — o documento não atravessa a fachada. Recência e uso único são conferidos aqui.
  • Pix (sendPix): no portão do baas (HolderAuthorizationService.authorize), sobre o payload canônico montado por operationHashOf com o desafio de baas_authorization_challenges. O banking monta o eco com as mesmas funções (pixTransferOperation, operationHashOf) e repassa a holderAuthorization; não verifica de novo.

Fachada nova. IBaasFacade (src/shared/facades/baas.facade.ts), local e remota, com os dez métodos que a fatia usa. Dinheiro atravessa em centavos inteiros; a conversão para reais com duas casas é feita num lugar só.

Extensão do baas que esta fatia traz. holderAuthorization.requireBiometric (só eleva: exige a selfie abaixo do limiar; false não dispensa nada acima) e o aperto de mão callerStepUp: true na política. Sem o aperto de mão (um baas anterior descartaria o campo em silêncio), o banking recusa a selfie que decidiu com STEP_UP_UNAVAILABLE, antes de pedir a selfie à pessoa. E a rota POST /accounts/:id/holder-identity-verifications (BAAS_ADMIN): "esta sessão biométrica prova o titular?" — aprovada, fluxo que compara rosto, ambiente aceito, identityProven e subjectMatches contra o documento da política. Responde { proven, reason, signedAt }; biometria fora do ar é 503, nunca proven: false.

Cadastro de aparelho (registerDevice). A chave mora no baas; aqui fica o índice, o rótulo, o nível declarado e a atestação registrada (attestation_status: unverified com token, absent sem — nunca "atestado"). Onde cada exigência é conferida, e nenhuma tem segunda implementação:

ExigênciaOndeRecusa
sem sessão: rascunho bind_device deste host, vivo, código conferido, não usadoBankingOnboardingServiceONBOARDING_UNKNOWN, OTP_NOT_VERIFIED
nonce de atestação deste banco, vivo, de uso únicoaqui, atualização condicionalATTESTATION_NONCE_UNKNOWN / _USED / _EXPIRED
selfie aberta por ESTE bloco para esta conta (e este rascunho), não gastaaqui (biometric_sessions)IDENTITY_NOT_PROVEN (BIOMETRIC_SESSION_UNKNOWN / _USED)
selfie amarrada a deviceEnrollmentHash(conta, chave)aqui (função do baas) e no baas… (ENROLLMENT_MISMATCH)
aprovada, identidade provada, do titular, recenteDeviceIdentityService.verify → baas.verifyHolderIdentity (o mesmo do login)… (BIOMETRIC_* / EVIDENCE_STALE)
chave P-256 válida, não repetida, evidência amarradabaas.enrollDeviceKeyBAD_REQUEST, DEVICE_ALREADY_REGISTERED

Depois do cadastro, a selfie vira a prova de identidade do aparelho (device_identity_proofs) — o primeiro login dele entra só com a assinatura. Se essa gravação perder para o índice único (a selfie já abriu um login), a chave recém-cadastrada é revogada no baas.

A selfie. createBiometricSession pede ao baas (openHolderBiometricSession) a sessão AUTHENTICATION contra o cadastro facial ativo mais recente do titular — o CPF sai da política do baas e vai direto ao biometrics (SUBJECT_TEMPLATE); o banking nunca o vê. A resposta traz capture (submitUrl, roteiro, janelas) para a câmera do aplicativo e capture.pageUrl, a página de captura hospedada do biometrics (o handoff.captureUrl), para o aplicativo abrir num WebView com as dicas em tempo real em vez de gravar ele mesmo. A sessão abre com a marca do banco: o theme.primary, o theme.radius e o brand.logoFileId (arquivo do file-storage da organização) do manifesto publicado no cms viram a appearance da sessão (página em tela cheia); sem manifesto ou com o cms fora, abre com a aparência configurada no biometrics. Selfie aberta sem caminho de captura (biometrics sem BIOMETRICS_PUBLIC_URL) é DEPENDENCY_UNAVAILABLE — nunca uma sessão sem capture. O captureToken e o pageUrl (que leva o token no caminho) são segredos de uso único: só na resposta, nunca em log nem em evento. getBiometricDecision lê a evidência assinada do biometrics (verifyEvidence): o aplicativo nunca decide. Selfie aberta e ainda sem captura (ou em avaliação) não tem evidência — o bloco responde 400 — e a decisão é PENDING (ou REJECTED, se a sessão venceu); nunca 500.

Aparelho novo no login. A regra mora num lugar só, DeviceIdentityService.requiresProof: o aparelho é novo para o login enquanto nunca provou identidade nesta conta — não há linha em device_identity_proofs para (dono, conta, aparelho). É o "primeiro login do aparelho" do servidor de exemplo, medido pelo que este bloco grava. A janela de horas (newDeviceHours, contada de baas_device_keys.created_at) continua valendo só para os limites reduzidos do Pix e para customer.newDevice. Chave recadastrada é outro deviceId e pede selfie de novo.

No primeiro login, depois da assinatura e antes de queimar o desafio, o corpo precisa trazer biometricSessionId de uma selfie que:

  • foi aberta por este bloco para este aparelho (createBiometricSession com deviceId) — recusa IDENTITY_NOT_PROVEN (BIOMETRIC_SESSION_UNKNOWN), sem consultar a biometria;
  • e, conferido no baas e aqui:
ExigênciaOnde é conferidaRecusa
aprovada, evidência assinada válidabaas → biometricsIDENTITY_NOT_PROVEN (BIOMETRIC_INVALID)
ambiente aceito para a contabaas… (BIOMETRIC_ENVIRONMENT_INVALID)
fluxo que compara rosto, identidade provada (prova de vida sozinha não prova)baas → biometrics… (BIOMETRIC_FLOW_INVALID / BIOMETRIC_IDENTITY_NOT_PROVEN)
do mesmo titular da contabaas (documento da política)… (BIOMETRIC_SUBJECT_MISMATCH)
recente: assinada há até BANKING_NEW_DEVICE_SELFIE_MAX_AGE_SECONDSbanking… (EVIDENCE_STALE)
de uso únicobanking, índice único (organização, sessão biométrica)… (BIOMETRIC_SESSION_USED)

Sem biometricSessionId: IDENTITY_NOT_PROVEN (BIOMETRIC_REQUIRED), sem consultar nada. O motivo vai em details.reason e em banking.session.refused; a selfie nunca vai para o log. Falha fechada: baas ou biometria fora do ar no login de aparelho novo é 503 DEPENDENCY_UNAVAILABLE — não entra. Aparelho conhecido entra só com a assinatura e não consulta a biometria (nem quando ela está fora do ar). banking.session.opened diz como entrou (identityProof: selfie | known_device).

7.1 Step-up por valor: quando a selfie é exigida

Decisão do dono (30/09): "para envio de valores baixos a biometria [do aparelho] era suficiente, a facial não era necessária". A assinatura do chip é sempre exigida (digital/rosto do aparelho); a selfie se SOMA a ela acima dos limites, nunca a substitui.

A regra (decideStepUp, services/step-up.ts). Selfie quando qualquer uma dispara; senão só a biometria do aparelho. "Acima" é estrito: no limite exato, digital.

Regra (selfieTriggers)Dispara quandoPadrão da plataforma
ABOVE_AMOUNTvalor > selfieAboveCentsR$ 1.000 (100000)
NEW_RECIPIENT_ABOVEfavorecido nunca recebeu desta conta e valor > selfieNewRecipientAboveCentsR$ 500 (50000)
NIGHT_ABOVEnoite (fuso de São Paulo, a mesma janela do limite noturno) e valor > selfieAtNightAboveCentsR$ 500 (50000) — abaixo do limite noturno de Pix (R$ 1.000), senão a regra nunca valeria
CUMULATIVE_DAY_ABOVEenviado hoje + este valor > selfieCumulativeDayAboveCents — contra o fracionamento (5 × R$ 999)R$ 1.000 (100000)
NEW_DEVICEaparelho na janela de "novo" e selfieOnNewDevice: truefalse: aparelho novo só pelos limites; o limite reduzido de aparelho novo (R$ 200 por Pix) continua valendo
POLICY_ALWAYSfaceStepUp: "always" em PUT /settings—
PROVIDER_THRESHOLDvalor > requireBiometricAbove da política da conta no baaso portão confere de qualquer jeito; a política nunca o rebaixa

riskReasons continua trazendo TODOS os motivos presentes (a tela os mostra), mesmo quando nenhum disparou a selfie. O contrato do aplicativo não mudou: ele recebe stepUp na intenção e só abre a selfie quando é "selfie" — device_biometric (ou qualquer outro valor) é só a biometria.

Três níveis, campo a campo (resolveStepUpPolicy, o único lugar que os combina). O mais específico vence; campo ausente ou null herda do nível de cima.

  1. Plataforma — env BANKING_STEPUP_* (§13). Só muda por deploy. É o padrão, e se declara como padrão: origem platform.
  2. Cliente (organização) — o banco ou fintech que usa a plataforma: PUT /admin/settings/step-up. É o "override por cliente". Origem organization.
  3. Conta (correntista) — opcional: PUT /admin/accounts/:accountId/step-up. Origem account.

Teto e piso. A organização só AFROUXA até o teto da plataforma (BANKING_STEPUP_MAX_*, padrão R$ 5.000 por limite). A conta só ENDURECE: cada limite ≤ o que vale para a organização, e selfieOnNewDevice: false não desliga o que a organização liga. Escrita fora disso é 400 com a razão (details.reason: ABOVE_PLATFORM_CEILING ou ACCOUNT_LOOSER_THAN_ORGANIZATION). A leitura aplica os dois de novo — o teto pode baixar por deploy e a organização pode endurecer depois do override da conta: limite da organização acima do teto vale o teto (origem platform), e o da conta mais frouxo que o da organização é ignorado.

Regra antiga (legado). Organização que gravou stepUpThresholdCents/selfieReasons em PUT /settings e ainda não gravou política nova não afrouxa em silêncio: a regra antiga vale convertida, com origem legacy — stepUpThresholdCents vira selfieAboveCents; NEW_RECIPIENT, NIGHT em selfieReasons viram os limites correspondentes em 0; NEW_DEVICE vira selfieOnNewDevice: true. A primeira gravação de política nova substitui o legado (o evento traz legacyBefore).

No envio, de novo. A intenção decide; o envio (sendPix) confere duas bordas que a intenção não enxerga, sem a selfie:

  • acumulado do dia, somado DEBAIXO da trava da reserva (claimForSend): duas intenções criadas em paralelo, cada uma abaixo do limite, não passam as duas sem selfie;
  • noite: intenção às 19:59 sem selfie, envio às 20:01 acima de selfieAtNightAboveCents.

Nos dois casos nada é reservado nem enviado: BIOMETRIC_REQUIRED com details.reason: "STEP_UP_ESCALATED", details.stepUp: "selfie" e details.selfieTriggers. O aplicativo abre a selfie sobre o MESMO hash e reenvia com biometricSessionId; o envio manda requireBiometric: true e o portão a confere.

Na trilha. banking.pix.intent.created leva stepUpPolicy (os valores que valeram), stepUpPolicySources (a origem de cada campo) e selfieTriggers (o motivo); a intenção grava o mesmo em pix_intents.step_up_policy. Toda mudança de política publica banking.settings.step_up.updated (organização) ou banking.account.step_up.updated / .deleted (conta) com changedBy, before/after (o que o nível gravou) e effectiveBefore/effectiveAfter (o que passou a valer, com as origens); metadata.userId é quem mudou. A escrita é condicionada à versão lida: duas gravações simultâneas, a segunda é 409 — o "antes" da trilha nunca é inventado.

Obsoletos em PUT /settings (aceitos e devolvidos por um ciclo): selfieReasons e stepUpThresholdCents. Só valem como LEGADO, convertidos, enquanto a organização não grava a política nova.

7.2 Abertura de conta com documento e selfie (rascunho open_account)

Decisão do dono (30/09): "o cadastro com documento; a pessoa inclui o rosto, o biometrics registra, e depois disso pode usar o rosto para autenticar." O caminho, com a trilha de cada passo:

  1. startOnboarding(cpf, purpose: 'open_account') — rascunho NEUTRO (igual para todo CPF; nada é perguntado ao baas), com o CPF cifrado (pii_cipher, AES-256-GCM com chave derivada de BANKING_SESSION_TOKEN_SECRET). Vive BANKING_ACCOUNT_OPENING_TTL_SECONDS (1 h). Sem purpose, é o revínculo de sempre.
  2. updateOnboarding — nome, nascimento (18+), e-mail, profissão. A trilha diz QUAIS campos.
  3. sendOnboardingOtp(id, phone) — o código vai ao telefone INFORMADO: é a posse dele que o código prova. Telefone trocado derruba a conferência anterior. verifyOnboardingOtp como no revínculo (tentativa gasta antes de comparar, OTP_LOCKED no teto).
  4. getLegalDocument / submitConsents — os documentos do manifesto publicado no cms (legalDocuments: lista de { key, version, title, body, required, publishedAt }, ver o README do cms). O hash é calculado aqui: SHA-256 de "<versão>\n<texto>" — versão nova pede aceite de novo mesmo com o texto igual. O aceite exige todos os required, na versão publicada agora (LEGAL_DOCUMENT_OUTDATED com a lista), depois do código e ANTES da selfie; opcional aceito entra no registro. O formato antigo do TenantManifest (legal + legalDocuments por id, hash só do texto, todos obrigatórios) continua aceito. Trocar o telefone depois do código publica banking.onboarding.phone.changed (verificationDropped), nunca o número.
  5. createBiometricSession({ flow: 'ONBOARDING', onboardingId }) — o biometrics abre a sessão ONBOARDING com reference: DOCUMENT_IMAGE sem arquivo e requireSubjectMatch: a página hospedada fotografa o documento (com dicas), o motor lê o CPF impresso e o compara com o do rascunho, e só então faz a selfie com prova de vida e o match rosto × documento. Até 3 selfies por rascunho (SELFIE_ATTEMPTS_EXHAUSTED fecha o rascunho). Falta dado, código ou termo: 409 ONBOARDING_INCOMPLETE com details.missing.
  6. A conta abre na leitura do rascunho (getOnboarding, ou getBiometricDecision da selfie de abertura), com a selfie APROVADA e a âncora formada (enrollment.anchor), em passos idempotentes e uma réplica por vez (opening_lease_until): holder_check (CPF já titular → KYC_REJECTED, motivo HOLDER_EXISTS na trilha) → person (people: cria ou reconhece pelo CPF, com os dados declarados) → phone_possession (a posse do telefone do código vira posse provada no people) → account (baas, conta com metadata.onboardingId; retomada acha a mesma) → holder_policy (HOLDER_SIGNATURE com o CPF) → demo_credit (só com o modo demonstração declarado) → enable (habilitada no aplicativo, POR ÚLTIMO: sem política a conta nunca é servida). Cada passo publica banking.onboarding.opening.step; falha publica banking.onboarding.opening.failed com o passo e o código, e a leitura seguinte (depois de 5 s) retoma do que falta. Aberta: banking.onboarding.account.opened e status: ACTIVE com o accountId. Enquanto abre, a leitura devolve o andamento (opening: { step, retrying }): o passo em curso (selfie = a biometria ainda decide) e se a última tentativa dele falhou. Nunca o código do erro — é o que deixa o aplicativo dizer "Criando sua conta…" e, preso num passo, oferecer o atendimento.
  7. registerDevice — como no revínculo: selfie AUTHENTICATION (onboardingId) contra a âncora recém-formada, amarrada ao hash (conta, chave). É a prova de que "depois disso o rosto autentica".

Status no fio: DRAFT (faltam passos, ou a última selfie foi reprovada e cabe outra) → KYC_IN_PROGRESS (selfie aprovada, abrindo) → ACTIVE; PENDING_KYC = selfie inconclusiva (revisão); KYC_REJECTED = recusa definitiva (o motivo só na trilha: banking.onboarding.rejected).

Retomada (step). Em DRAFT, a LEITURA (getOnboarding) diz o próximo passo que falta: personal_data → phone (sem telefone, ou o código venceu sem conferir) → otp (código vivo) → terms → document. É o que deixa o aplicativo retomar a abertura depois de fechar no meio: ele guarda só o onboardingId no armazenamento seguro do aparelho e, ao reabrir, lê o rascunho. Não há busca por CPF: quem tem o id é quem começou naquele aparelho (o id é a prova de posse), e o passo não diz nada do que foi declarado. Rascunho vencido ou desconhecido continua ONBOARDING_UNKNOWN — o app descarta. startOnboarding com o mesmo CPF continua criando outro rascunho, de propósito: devolver o rascunho existente por CPF seria um oráculo ("este CPF começou uma abertura").

Modo demonstração (declarado, por organização). PUT /admin/settings/onboarding { demoInitialBalanceCents } — só a organização principal (subconta: recusa com banking.settings.onboarding.refused); cada gravação publica banking.settings.onboarding.updated com o antes e o depois. > 0 credita esse valor em toda conta aberta pelo aplicativo (só MockBank; banco real recusa, a conta abre mesmo assim e a recusa vai para a trilha). A âncora sem CPF conferido é do biometrics (documentAnchorWithoutCpfCheck na política dele) e sai marcada demo no evento de abertura (anchorStrength).

7.3 Acesso pelo computador, aprovado no celular (canal desktop)

O computador não tem chave de aparelho. Quem prova que é o titular é o celular dele, com a mesma verifyOperationSignature do login (decisões do dono, 01/10: DD1 só aprovação no celular, DD2 Pix do computador só na fatia 2, DD3 sessão em cookie httpOnly por repasse de mesma origem, DD4 só o titular).

  1. O computador pede — POST /v1/desktop/access-requests { cpf }, sem sessão, pelo host, sob o limite das rotas sem sessão e o do cadastro (IP 10 min, CPF 1 h, chaves desktop-ip:/desktop-doc:). Recebe { requestId, conferenceNumber, pollToken, expiresAt, pollAfterSeconds }. A resposta é igual para quem é e quem não é cliente, com o mesmo tempo mínimo: CPF sem conta, com mais de uma, ou de conta que já tem BANKING_DESKTOP_MAX_PENDING pedidos pendentes gera um pedido com account_id vazio, que nunca aparece em celular nenhum e só vence. A diferença fica na trilha (targeted, reason). O CPF não é guardado; o token de consulta, só como SHA-256.
  2. O celular lista — GET /v1/approvals (sessão do app): os pedidos PENDING da conta DA SESSÃO, com o nonce sorteado aqui, três números distintos embaralhados (um é o do computador) e o navegador como ele se declarou ("Chrome no Windows").
  3. O titular aprova — POST /v1/desktop/access-requests/:requestId/approve { deviceId, conferenceNumber, signature }. A assinatura é sobre catalisa.banking.desktop-access.v1:<requestId>:<nonce>:<número tocado> (desktopAccessApprovalPayload, cópia byte a byte do banking-core, com o mesmo vetor nos dois repositórios). Ordem: pedido desta conta → vivo → aparelho do corpo = o da sessão, ainda do titular no baas → assinatura confere → número é o do computador. Número errado com assinatura válida queima o pedido (CONFERENCE_NUMBER_MISMATCH); assinatura inválida não queima. A transição é updateMany condicionado a estado, prazo e conta: o celular de outro titular não casa linha nenhuma e recebe ACCESS_REQUEST_UNKNOWN, como o id inventado.
  4. O computador recolhe — POST /v1/desktop/access-requests/:requestId/poll { pollToken } (POST porque o token é segredo e a consulta aprovada consome). pending | refused | expired, ou approved UMA vez: a sessão nasce, o token vai no cookie __Host-banking_desktop (HttpOnly; Secure; SameSite=Strict; Path=/, cookie de sessão do navegador: sem Max-Age, fechar o navegador o apaga) e o corpo leva só { expiresAt, customer }. Depois da aprovação há BANKING_DESKTOP_CLAIM_SECONDS para recolher.

A sessão desktop (sessions.channel) leva o aparelho que APROVOU: revogá-lo encerra o computador. Vive BANKING_DESKTOP_SESSION_TTL_SECONDS (absoluto), cai sem pedido por BANKING_DESKTOP_IDLE_SECONDS — conferido no servidor a cada pedido, pela coluna last_seen_at (marcada no máximo a cada 15 s), com banking.desktop.session.idle_expired — e não renova (o refresh dela nunca sai; refreshSession a recusa). Só alcança as rotas que o mapa marca desktop: true — getManifest, getCapabilities, getHome, getStatement, getTransaction, getHolds, getHold, getLiens, getPixLimits, closeSession, as duas do pedido e, desde a fatia 2 (§7.4), getPixContacts, lookupPixKey, getDevices, signOutDesktop, createDesktopOperation, getDesktopOperation, cancelDesktopOperation e getDesktopSessions. O resto é 403 CHANNEL_NOT_ALLOWED no sessionMiddleware, inclusive nas rotas optional (que não caem para "sem sessão") e com o token apresentado como Bearer; createPixIntent, sendPix e registerDevice recusam de novo no serviço. O módulo do mapa não carrega se uma rota money ou com assinatura for marcada desktop. Token vindo do cookie só vale se a sessão for desktop.

Sair do computador: POST /v1/desktop/sessions/revoke encerra toda sessão desktop da conta (banking.desktop.session.revoked, com quantas e by: holder | desktop) e cancela os pedidos de operação ainda pendentes. Vale do celular e, desde a fatia 2, do próprio computador ("sair de todos"); no computador, DELETE /v1/session encerra só a dele e apaga o cookie.

Trilha. banking.desktop.access.requested, .throttled, .approved, .refused (com reason: HOLDER_REFUSED, WRONG_NUMBER, SIGNATURE_INVALID, DEVICE_MISMATCH, DEVICE_UNKNOWN, NOT_FOUND_FOR_ACCOUNT, ALREADY_*, ALREADY_CLAIMED, EXPIRED), .expired, banking.desktop.session.opened, .revoked e .idle_expired. Toda recusa de rota pela sessão do computador (CHANNEL_NOT_ALLOWED) vai pelo registro geral (§14.1), com a sessão. Nunca CPF, IP, token de consulta, nonce ou assinatura.

7.4 Operações pedidas pelo computador, aprovadas no celular (fatia 2)

A sessão do computador continua sem mover dinheiro. Ela só pede; quem executa é o celular do titular, pelo MESMO caminho do aplicativo (createPixIntent + sendPix): step-up por valor, limites do canal, saldo gastável, idempotência do envio e a trilha do Pix valem iguais. O mapa de rotas ganha UMA rota do computador que escreve — createDesktopOperation, write, sem assinatura —; nenhuma money passou a ser alcançável por ele (o boot continua quebrando se uma for marcada).

  1. O computador pede — POST /v1/desktop/operations { kind: 'pix_transfer', recipientKey, amountCents, description?, source: 'key' | 'br_code' | 'contact', idempotencyKey } (a chave também pode vir em Idempotency-Key; se vierem as duas, iguais). Só o canal desktop: o celular recebe CHANNEL_NOT_ALLOWED (ele tem o Pix dele). Conferências baratas: valor inteiro > 0 (AMOUNT_INVALID), chave com forma e existente no DICT (RECIPIENT_NOT_FOUND), até o limite do dia (DAY_LIMIT), conta sem pix.send pausado (CAPABILITY_PAUSED) e no máximo 3 pedidos vivos na conta (DESKTOP_OPERATION_LIMIT, 409). A chave de idempotência é única por (organização, sessão): repetir devolve o mesmo pedido; a mesma chave com outro valor, destino ou descrição é IDEMPOTENCY_KEY_MISMATCH. Prazo: 300 s.
  2. O celular lista — GET /v1/approvals devolve, junto dos pedidos de acesso (kind: 'desktop_access'), os de operação (kind: 'desktop_pix'): valor, descrição, favorecido (nome, chave, documento mascarado, instituição, PF/PJ), navegador do computador e prazo. Estados pending e confirming.
  3. O celular confirma — POST /v1/desktop/operations/:requestId/intent (sessão do app) abre a intenção de Pix com o valor, a chave e a descrição lidos da linha — o celular não manda nenhum dos três — pelo createPixIntent de sempre, confere que a intenção gravada é a da linha e a liga ao pedido (updateMany condicionado: duas ligações, uma vence; o índice único em intent_id impede uma intenção em dois pedidos). Devolve o PixIntent: o celular assina o payload canônico do BaaS (conta, desafio, nonce, valor, chave) com a digital e chama o mesmo sendPix. Uma assinatura só: o vínculo pedido → intenção é deste servidor. Chamar de novo com o desafio vencido troca a intenção: a anterior é abandonada antes (PENDING → FAILED SUPERSEDED, condicionado — se ela já saiu, DESKTOP_OPERATION_USED), e a ligação nova só casa com a anterior certa. Regra que recusa a intenção (limite, saldo gastável, favorecido, pausa) fecha o pedido em failed com o código; PIX_PENDING_CONFIRMATION, RATE_LIMITED e 5xx não fecham.
  4. "Não fui eu" — POST /v1/desktop/operations/:requestId/refuse (celular) e desistir — POST .../cancel (computador). Com intenção aberta, ela é abandonada ANTES, condicionada a ainda não ter saído: contra o sendPix em corrida, um vence (DESKTOP_OPERATION_USED se foi o envio). Recusar o já recusado (e cancelar o já cancelado) não é erro.
  5. O computador acompanha — GET /v1/desktop/operations/:requestId (pollAfterSeconds: 3). O estado é assentado na leitura a partir da intenção, com transição condicionada (várias leituras, um evento):
LinhaIntençãoNo fio
PENDING, no prazo—pending
PENDING, vencido—expired (fecha)
APPROVINGPENDING (ou trocada)confirming
APPROVINGSENDING / UNKNOWNprocessing
APPROVINGCOMPLETEDcompleted + transactionId (fecha)
APPROVINGFAILEDfailed + failureCode (fecha)
APPROVING, vencido e desafio vencidoPENDINGexpired (abandona a intenção e fecha)
COMPLETED / FAILED / REFUSED / CANCELLED / EXPIRED—o mesmo, minúsculo

A chave do favorecido é apagada da linha no estado final (o comprovante vem do extrato). GET /v1/desktop/sessions lista as sessões do computador vivas na conta, com o navegador e a corrente.

Trilha. banking.desktop.operation.requested (valor, origem, sessão), .approving (pedido, intenção, aparelho, step-up, intenção trocada), .completed, .failed (código), .refused (o "não fui eu" com reason: HOLDER_REFUSED E toda recusa das rotas de operação, com method e code), .cancelled (DESKTOP_CANCELLED ou DESKTOP_SIGNED_OUT) e .expired. O envio em si é o banking.pix.transfer.* de sempre, ligado pelo intentId. Nunca a chave nem o nome do favorecido.

O que NÃO se defende. A pescaria continua possível: o golpista com a sessão do computador pede um Pix para a chave dele e convence a vítima a confirmar no celular. O que reduz: o celular mostra valor, favorecido, documento mascarado e a chave antes da digital, avisa que ninguém do banco pede isso, e o envio passa pelo step-up do app (favorecido novo e valor alto pedem selfie). O que não existe: assinatura própria do pedido além da do Pix (seria uma segunda digital para a mesma decisão).

7.4 Antecipação de recebíveis pelo serviço de crédito (BB-6)

Decisão do dono (24/09): antecipação flexível e modular — integra com serviços de crédito quando existirem, com toggle do serviço e do item de menu. O serviço de crédito NÃO é bloco deste repositório: é a ICreditFacade (src/shared/facades/credit.facade.ts), hoje sobre o CCP do painel pela porta /credit-channel/v1 (HttpCreditFacade). Plano completo: docs/design/conta-app/antecipacao-no-cbp.md no repositório do painel.

MétodoRotaCanalO que o banking faz
getReceivables / addReceivable/v1/receivablesapp e desktopportão products.receivables; titular pelo baas
simulatePOST /v1/credit/simulationsapp e desktopestimativa (indicative)
apply / getApplication/v1/credit/applicationsapp e desktop+ portão credit.contract; análise enfileirada; Idempotency-Key opcional (ver abaixo)
getProposalGET /v1/credit/proposals/:idapp e desktoprecorte do cliente
createRepaymentLockChallengePOST /v1/credit/proposals/:proposalId/repayment-lock/challengesó celulardesafio + operação lien.create montada da proposta (ver "Trava na conta do cedente")
acceptProposalPOST /v1/credit/acceptancessó celularselfie presa ao termHash (ou, com trava, à operação lien.create), reserva única, trava no baas, serviço
getOperations / getOperation/v1/credit/operationsapp e desktopsem portão (desligar não esconde dívida); crédito único
requestCreditAcceptance / getCreditAcceptanceRequest/v1/credit/acceptance-requestssó desktoppedido que o celular aprova
refuseCreditAcceptancePOST …/:requestId/refusesó celularrecusa o pedido

Canal v2 (plano #1990, 19/10). O perfil do app (PUT …/admin/profile do banking, campo creditChannelVersion, v1 padrão; ausente no PUT mantém o atual) escolhe a porta do serviço: /credit-channel/v1 (ICreditFacade, congelada) ou /credit-channel/v2 (ICreditChannelV2, src/shared/facades/credit-channel-v2.facade.ts). Na v2 o app lê GET /v1/credit/offers (devolve { channelVersion, offers }; num host v1, lista vazia sem chamar nada), GET /v1/credit/evidence?offer=, GET|POST /v1/credit/items (só v2; num host v1, CAPABILITY_OFF com reason: CREDIT_CHANNEL_V1) e manda { offer, itemIds } às MESMAS rotas de simulação e pedido (corpo da outra versão: 400 CREDIT_CHANNEL_VERSION). Proposta (lines no lugar de perNote), aceite, trava e operações são a mesma porta nos dois canais. Portão da v2: products.credit; não cadastrada, vale products.receivables.

Opt-in. No feature-flags, chave não cadastrada é on. Para products.receivables e credit.contract o banking lê reason: UNREGISTERED como off — no portão e no getCapabilities (o menu some). Organização sem tenant no serviço (ORG_NOT_BOUND) também é CAPABILITY_OFF.

O titular. O CPF vem do baas (revealHolderDocument, propósito credit, uma linha baas.authorization.holder_document.revealed por revelação; cache de 5 min por réplica), nunca do pedido. Nome e telefone (do people) só vão na nota avulsa, para o serviço abrir o cedente.

Linhas entre o desconto e o líquido (10/10). Para estas linhas o banking é fachada e não conhece produto. A estimativa e a proposta repassam deductions, a lista { code, label, cents } que o serviço de crédito manda (glosa, parcela não antecipada, IOF, tarifas, registro, seguro, ou o que o produto tiver), na ordem em que veio. Antes, a fachada repassava uma lista fixa de campos e o app mostrava um líquido R$ 727,89 menor que a estimativa sem linha que explicasse.

A lista é conferida só na forma, e este é o contrato que o serviço de crédito precisa honrar:

  • code: minúsculas, começa por letra, até 40 caracteres, sem repetição na lista (duas tarifas iguais vêm somadas ou com códigos distintos);
  • label: texto de tela de até 80 unidades UTF-16, sem espaço nas pontas, sem caractere de controle, de direção forçada nem os invisíveis mais comuns. Não é proteção contra marcação: quem desenha escapa o texto;
  • cents: inteiro com sinal — positivo desconta, negativo credita (bônus, subsídio), zero afirma "não há". null ("vem na proposta") só na estimativa;
  • até 24 linhas; lista ausente continua ausente (não é o mesmo que lista vazia) e nunca vem null;
  • na proposta, chave desconhecida numa linha é erro: o hash do serviço cobre a linha inteira. Na estimativa a chave a mais é descartada. A rigidez vale só para as linhas (campo novo no topo da proposta continua sendo descartado), e cria uma ordem: se o serviço de crédito acrescentar uma chave à linha, este bloco precisa ser publicado antes.

Qualquer linha fora da forma é resposta malformada: o app recebe DEPENDENCY_UNAVAILABLE do crédito. Este bloco não confere se as linhas explicam a diferença entre o desconto e o líquido; o app mostra o que sobrar como "Outros descontos". E só esta lista é agnóstica: o resto do contrato de crédito (perNote, receivableIds, a capacidade products.receivables) ainda tem a forma da antecipação de recebíveis.

Ordem de publicação: este bloco antes do painel. Aqui o hash é só comparado como texto, então o aceite funcionaria nas duas ordens; mas com o painel na frente o cliente aceitaria um hash que atesta linhas que este bloco ainda não deixa a tela mostrar.

NOTES_BELOW_COSTS (409): as notas escolhidas valem menos que os custos da operação. Tem código próprio porque não é caso de tentar de novo.

Faixa de valor (12/10). A estimativa (simulate) repassa amountRange: { minCents, maxCents } quando o serviço de crédito manda: dois números em centavos. É genérico de propósito: sem rótulo, sem nome de produto, e só as duas pontas passam.

  • minCents: null quer dizer mínimo não informado (o serviço não conseguiu ler a regra), e não "sem mínimo": o pedido ainda pode ser recusado por valor. maxCents: null = não há máximo declarado;
  • campo ausente quando o serviço não manda (serviço antigo): nada muda para quem não o lê;
  • conferido só na forma: cada ponta é inteiro não negativo até R$ 100 bilhões (MAX_SANE_CENTS, um teto de sanidade deste bloco, não uma regra de negócio) ou null, as duas presentes, mínimo não acima do máximo. Fora disso o campo é omitido e a estimativa segue. Faixa malformada nunca derruba a estimativa nem vira limite inventado;
  • o mínimo recusa; o máximo é informação para a tela. Quem decide é o serviço de crédito.

Recusas do pedido e da estimativa com código próprio (12/10). Antes saíam como BAD_REQUEST e a tela dizia "tente de novo" para casos em que tentar não muda nada.

CódigoStatusQuandodetails
AMOUNT_OUT_OF_RANGE409o valor fica abaixo do mínimo, em simulate e em applyreason (below-minimum), minCents, amountCents. Cada chave só vai se o serviço informou e passou na conferência abaixo
APPLICATION_UNDER_REVIEW409já há um pedido desta conta em análise; em applynenhum
APPLICATION_LIMIT_REACHED429limite de pedidos no período atingido; em applyretryAfterSeconds, só quando o serviço informa a espera; vira também o cabeçalho Retry-After da resposta

O que a tela precisa saber de AMOUNT_OUT_OF_RANGE:

  • amountCents é o total que o serviço comparou com o mínimo: a soma de TODOS os valores pendentes elegíveis do titular, não o que ele escolheu na tela. A frase do app não pode dizer "você pediu R$ X"; o que ela pode dizer com segurança é o mínimo (minCents);
  • maxCents não vem na recusa. No pedido o serviço manda null sempre (não lê o catálogo ali), e null na estimativa quer dizer outra coisa. A faixa completa só vem da estimativa (amountRange).

A fachada (refusalInfoOf) só deixa passar de uma recusa o que tem forma conhecida, conferido em conjunto:

  • reason é um conjunto fechado (hoje só below-minimum); qualquer outro texto é descartado;
  • minCents é inteiro até o teto de sanidade; se o serviço mandar um máximo menor que ele, o par é incoerente e nada dele passa;
  • amountCents só passa em below-minimum, com mínimo conhecido e valor de fato abaixo dele;
  • a espera: a do corpo (details.retryAfterSeconds, inteiro de 1 s a 7 dias) quando é válida; senão, e só num 429, o cabeçalho Retry-After em segundos inteiros (data HTTP e 1e3 não valem). Cabeçalho de 5xx ou de página de borda nunca é lido. AMOUNT_OUT_OF_RANGE e APPLICATION_UNDER_REVIEW nunca levam espera, mesmo que o serviço mande.

Texto livre e ids do outro lado ficam para trás, e a mensagem do serviço nunca chega ao app. Nenhuma das três recusas enfileira análise nem publica banking.credit.application.requested. Hoje o canal do painel não informa a espera do limite: retryAfterSeconds fica ausente e a frase do app é "tente mais tarde", sem hora. Na trilha, APPLICATION_LIMIT_REACHED sai como banking.credit.<assunto>.throttled (como RATE_LIMITED); as outras duas, .refused.

Código que este bloco não conhece continua saindo BAD_REQUEST. details.reason leva o código do serviço só quando ele tem forma de código (^[A-Z][A-Z0-9_]{2,63}$); o que não casa é texto livre e vira CREDIT_REFUSED. O aviso no log (recusa do serviço de crédito sem código próprio) segue a mesma regra: o código, ou só o tamanho do texto. É o sinal para mapear o código aqui e dar a ele uma frase em packages/banking-core (repositório do painel). tests/unit/banking/credit.test.ts tem a lista das recusas do canal com o destino de cada uma, e tests/unit/banking/credit-facade-http.test.ts tem o corpo literal do canal para as três recusas e a faixa: renomear um campo lá quebra um teste aqui.

Volume de log. O aviso de código desconhecido e o de faixa malformada saem uma vez por minuto por chave (LogWindow, em memória, por réplica); o aviso seguinte leva em suppressed quantos ficaram calados. Um serviço com defeito não gera uma linha por pedido.

Ordem de publicação. Este bloco pode ir antes ou depois do painel. Sem o painel novo, nada muda (campo ausente, códigos nunca emitidos). Com este bloco e o app antigo, os três códigos caem na frase genérica do app (como antes) até o banking-core ganhar as frases e o status deles em ORCHESTRATION_ERROR_STATUS.

O pedido é idempotente por Idempotency-Key (12/10). POST /v1/credit/applications aceita o cabeçalho Idempotency-Key (a mesma forma do Pix: 1 a 128 caracteres ASCII visíveis, sem espaço; fora disso, 400 BAD_REQUEST com details.field = idempotencyKey). Como no Pix, a chave também pode vir no corpo ({ receivableIds, idempotencyKey }); se vierem as duas, iguais — diferentes é 400 IDEMPOTENCY_KEY_MISMATCH. A chave diferencia maiúsculas de minúsculas (Abc e abc são dois pedidos). Cabeçalho só com espaços conta como ausente (a rota apara e trata vazio como "sem chave", igual ao Pix): o pedido segue pelo caminho legado, sem idempotência — o aplicativo não deve mandar cabeçalho em branco esperando proteção.

  • A chave é presa a um applicationId antes de o serviço de crédito ser chamado, em credit_application_requests: o id da linha é o applicationId. O índice único por (organização, conta, chave) faz dois toques ao mesmo tempo (ou duas réplicas) usarem a mesma linha; quem perde o insert lê a que ganhou.
  • A mesma chave → o mesmo applicationId enviado ao canal. O canal deduplica por applicationId (contrato dele: "repetir o mesmo applicationId devolve a resposta de sempre"), então a resposta que se perdeu é recuperada tocando de novo: 201 com o pedido e o id, e não APPLICATION_UNDER_REVIEW sem id; para titular habilitado, a análise não roda duas vezes.
  • "A mesma resposta" vale na janela de repetição, não para sempre. Este bloco não guarda a resposta: guarda o id e pergunta de novo ao canal, que responde pelo estado de AGORA. Enquanto o pedido está em análise ou com proposta aberta, a resposta é a de sempre. Depois que a proposta é aceita, repetir a chave devolve o que o canal diz então — 409 NO_PENDING_NOTES (as notas já foram antecipadas) —, não o 201 original. O que a chave garante sempre é o que importa: nenhum pedido novo e nenhuma análise nova nascem da repetição.
  • A chave repete por 7 dias; depois é recusada. BANKING_CREDIT_APPLICATION_KEY_TTL_SECONDS (padrão 604800; mínimo 60, máximo 29 dias). Linha criada há mais que isso → 410 IDEMPOTENCY_KEY_EXPIRED, sem chamar o canal, com warn no log e a chave na trilha da recusa. O motivo: o canal lembra de um applicationId por 30 dias (APPLICATION_TTL_SECONDS, no painel); repetir depois disso com o id antigo faria o canal tratar como pedido novo e enfileirar análise nova — e paga — que ninguém pediu. Por isso o teto da variável fica abaixo de 30 dias: se o prazo do canal for reduzido no painel, reduza este antes. A janela conta do nascimento da linha, respondida ou não. Entre devolver uma resposta gravada e recusar, a escolha foi recusar: o bloco não grava a resposta (ela é do canal e muda com o tempo), e uma recusa com código estável não deixa o aplicativo acreditar num estado velho. Quem recebe IDEMPOTENCY_KEY_EXPIRED consulta o pedido (getApplication) ou começa um pedido novo, com chave nova.
  • A mesma chave com outras notas → 400 IDEMPOTENCY_KEY_MISMATCH, sem chamar o serviço (o mesmo código e status do Pix e da operação do computador). A comparação é pela impressão das notas (request_hash, SHA-256 dos ids sem repetição e em ordem): a mesma seleção em outra ordem é o mesmo pedido. As notas em si não são gravadas aqui.
  • A chave vale para a conta, não para a sessão nem para o canal: repetir depois de entrar de novo, ou no computador, devolve o mesmo pedido. Outra conta com a mesma chave é outra linha.
  • Recusa do serviço não troca o id: dentro da janela, repetir a chave pergunta de novo com o mesmo id. Pedido novo (outra seleção, ou a mesma depois de respondida) pede chave nova — o aplicativo gera uma por intenção de pedir (UUID), e a reusa só nas repetições daquele envio.
  • Expurgo. A linha é guardada por 90 dias (APPLICATION_KEY_RETENTION_MS, constante do serviço): entre o fim da janela e esse corte ela existe só para manter a chave recusada. O expurgo é oportunista, como o dos desafios de sessão: quando nasce uma chave nova, saem as linhas da mesma conta mais velhas que o corte (usa o índice (organização, conta, criação); falha no expurgo não impede o pedido). Conta que nunca mais pede fica com as linhas dela — poucas, sem dado pessoal além dos ids. Para varrer tudo de uma vez, pelo operador:
    DELETE FROM banking.credit_application_requests WHERE created_at < now() - interval '90 days';
    
    Depois do expurgo a mesma chave nasceria como pedido novo; 90 dias é três vezes a memória do canal.
  • Ordem das recusas: portão (products.receivables + credit.contract) → forma da chave (400 BAD_REQUEST) → chave de outro pedido (IDEMPOTENCY_KEY_MISMATCH) → chave vencida (IDEMPOTENCY_KEY_EXPIRED). Serviço desligado responde pelo portão mesmo com chave malformada, e não grava linha. Chave malformada não vai à trilha como pista.
  • Trilha: banking.credit.application.requested sai em toda resposta com sucesso, com repeated: false na primeira resposta do serviço para a chave (quem virou answered_at, por updateMany condicionado) e repeated: true nas repetições. A recusa leva a chave (idempotencyKey) para o evento de recusa, como no Pix. O catálogo do analytics não declara repeated neste evento (o atributo é descartado lá): quem contar pedidos por evento conta as repetições; por applicationId, não.

Legado: pedido sem chave. Sem o cabeçalho, o comportamento é o de antes, para o aplicativo que ainda não o manda: applicationId = randomUUID() a cada chamada, nada gravado, evento sem repeated. O defeito continua valendo para ele — resposta perdida e segundo toque são um pedido NOVO (APPLICATION_UNDER_REVIEW sem o id para o não habilitado; análise duplicada para o habilitado). A chave não é obrigatória porque exigir quebraria o aplicativo publicado; tornar obrigatória é decisão para depois de o banking-core mandar a chave em todo pedido.

No aplicativo (packages/banking-core, repositório do painel). A rota apply de orchestration-routes.ts não declara idempotencyKeyField, então o cliente HTTP não manda o cabeçalho. Falta lá: idempotencyKey na entrada de apply e idempotencyKeyField: 'idempotencyKey' na rota — o mesmo molde de sendPix (o cliente copia o campo do corpo para o cabeçalho e manda os dois, que é o que esta rota aceita). E IDEMPOTENCY_KEY_EXPIRED: 410 em ORCHESTRATION_ERROR_STATUS, com a frase da tela ("este pedido já foi enviado há muito tempo; confira seus pedidos").

O aceite, em ordem. Portão → pedido do computador (se houver) → aceite já existente da proposta (repetição devolve a operação; UNKNOWN pergunta de novo com o MESMO id) → proposta relida, os dois hashes conferidos (PROPOSAL_CHANGED) → chave Pix ATIVA da conta (PIX_KEY_REQUIRED, antes da selfie) → selfie aberta por este bloco para esta conta, presa ao termHash, não gasta, provada no baas e recente → reserva (credit_acceptances: único por (organização, proposta) e por selfie) → serviço com acceptanceId = id da reserva → desfecho. Recusa do serviço solta a reserva; tempo esgotado deixa UNKNOWN e responde CREDIT_OUTCOME_UNKNOWN (504) — nunca aceite duas vezes.

A chave de destino (02/10). A abertura de conta não cria chave Pix, e a conta nasce em HOLDER_SIGNATURE: sem assinatura do aparelho, nem a máquina cadastra chave. Por isso o aplicativo cadastra a chave aleatória em Minhas chaves: createOperationChallenge({ type: 'pix.key.create', params: { keyType: 'EVP' } }) devolve o desafio do baas (step-up = digital; chave não move dinheiro) → o aparelho assina pix.key.create {keyType:'EVP'} → createPixKey → o portão do baas confere e queima o desafio. getPixKeys lista só as ATIVAS. Só EVP nesta fatia (CPF/telefone/e-mail pedem posse do dado). Pausar pix.keys na conta fecha as duas rotas. No MockBank a chave entra no DICT simulado do processo do baas: o Pix do fundo para ela credita a conta (sem a chave, o mock debita o fundo e não credita ninguém).

Crédito único. Quando o serviço devolve a operação como desembolsada/liquidada*, credited_at vira por updateMany … where credited_at is null, e só quem virou publica banking.credit.operation.credited. A conferência roda nas leituras de operação (app, desktop, console).

Trilha. banking.credit.receivable.added, .simulated, .application.requested, .acceptance.requested|declined|expired, .proposal.accepted, .acceptance.unknown, .operation.credited; toda recusa pelo registro geral (§14.1) como banking.credit.<assunto>.refused|failed|unknown. Nunca CPF, nome, telefone, chave Pix ou texto do termo. Trava na conta do cedente (assignor-account-lock, 16/10). Quando o produto do serviço de crédito paga a operação com o que ENTRA na conta do cedente (o repasse da fonte pagadora cai na conta CBP do médico), a proposta traz repaymentLock { amountCents, destinationAccountId, sourceDocuments, externalReference, expiresAt, payerName? }. Vem na proposta, e não só na resposta do aceite, porque a trava tem de existir antes de o serviço aceitar e desembolsar — "nada de adiantamento sem trava". Conferida só na forma (repaymentLockOf): sourceDocuments com 1 a 20 CPFs/CNPJs (vazio é recusado — o desembolso cai na mesma conta e seria capturado de volta), conta UUID, vencimento legível; trava malformada é proposta malformada, nunca "sem trava".

  1. Desafio. createRepaymentLockChallenge relê a proposta, pede o desafio ao baas e devolve { challengeId, nonce, expiresAt, notBefore, operation: { type: 'lien.create', accountId, params }, operationHash }. Os params são os termos da trava + consentReference = termHash + o mandato hold.settle (teto = valor, validade = vencimento) — lienCreateOperation do baas, a mesma do banking-core. O app reconstrói da proposta que mostrou e confere o hash.
  2. Selfie e assinatura. A selfie do aceite é aberta presa ao operationHash (que leva o termHash dentro: uma selfie consente o termo E a trava); o aparelho assina o payload canônico.
  3. Aceite. acceptProposal com repaymentLock: { notBefore, nonce, holderAuthorization: { deviceId, challengeId, signature } }. Ordem: hashes → trava declarada × consentimento (REPAYMENT_LOCK_CONSENT_REQUIRED / REPAYMENT_LOCK_UNEXPECTED) → chave Pix → selfie presa ao hash da trava → reserva → baas.createLien (chave derivada do aceite; a selfie como degrau, requireBiometric: true; pendingAcceptance: true — a trava nasce PENDING_ACCEPTANCE e não captura nada) → serviço de crédito (recebe repaymentLock: { lienId, externalReference }) → aceito, baas.activateLien (idempotente; só agora a trava captura; banking.credit.repayment_lock.activated).
    • a trava (repetida pela chave) já não vale — venceu pendente, foi desfeita, ou faltam menos de 2 min para o prazo do aceite: o serviço não é chamado (aceite tardio não existe), a pendente é desfeita, e o aceite falha com REPAYMENT_LOCK_EXPIRED (banking.credit.repayment_lock.expired; needsReview: true quando o aceite estava UNKNOWN e pode ter chegado ao serviço);
    • a ativação sem desfecho (3 tentativas): aceite UNKNOWN, CREDIT_OUTCOME_UNKNOWN e o alarme banking.credit.repayment_lock.activation_failed (stage: unknown); a repetição — mesmo acceptanceId, mesma trava — ativa de novo;
    • o baas recusa ativar depois do aceite (o prazo venceu no meio): a operação existe no serviço, mas sem trava — e o painel não a desembolsa (exige a trava ATIVA). O aceite fica UNKNOWN, sai o alarme activation_failed (stage: refused, needsReview: true, com proposalId e operationId) e o app recebe REPAYMENT_LOCK_EXPIRED, a resposta amigável de "comece de novo";
    • o banco recusa a trava: o aceite falha com REPAYMENT_LOCK_REFUSED (details.gate = motivo do portão) e a reserva sai — só se nunca foi ao serviço;
    • a trava sem desfecho: aceite UNKNOWN; repetir reencontra a MESMA trava pela chave;
    • o serviço recusa o aceite: a trava é desfeita (cancelLien; a pendente sai sempre) e sai banking.credit.repayment_lock.rolled_back (abandoned: false = alarme: um operador cancela);
    • o serviço ecoa uma trava diferente da criada: vale a criada (a assinada) e sai banking.credit.repayment_lock.mismatch.
  4. No app. getLiens lista as travas (a pedida por um aceite que nunca foi ativada não aparece) (LienWire: rótulo "Adiantamento nº …", payerLabel, remainingCents, frase pronta); a tela monta "o próximo repasse do Hospital X vai cobrir R$ …". O bloqueio ADVANCE da captura aparece em Valores bloqueados como "Bloqueado para o adiantamento".

A captura, os eventos baas.lien.* e o consentimento por trás estão no README do baas (§8, "Trava de recebíveis futuros").

Nota avulsa: competência e data do serviço (16/10). addReceivable aceita competencia (YYYY-MM) e dataServico (YYYY-MM-DD), opcionais, repassados como vieram ao serviço de crédito. O padrão (mês anterior ao da emissão) é do app; ausente continua ausente.

Repasses confirmados disponíveis (16/10). getConfirmedPayouts (GET /v1/credit/confirmed-payouts, app e desktop) devolve os repasses que a fonte pagadora confirmou e ainda não foram adiantados ({ reference, payerName, competence, amountCents, availableCents, payoutDate, source }), lidos do confirmedPayouts que o serviço de crédito manda junto das notas (no topo do envelope ou em meta). Serviço que não manda = lista vazia (o app não mostra nada); item fora da forma é descartado com aviso (confirmedPayoutsOf) — é oferta de tela, não dinheiro.

7.6 Abertura de conta de EMPRESA, multi-sócio (open_company_account)

Decisão do dono (24/09): "PF e PJ na v1, os dois, incluindo PJ multi-sócio e todas as características dela". Plano completo, com as fatias e as decisões abertas: docs/design/conta-app/abertura-pj.md no repositório do painel. Fatia 1 (01/10), o que este bloco faz:

PassoRotaO que aconteceDono
1. CNPJstartCompanyOnboardingCaso DRAFT igual para todo CNPJ válido; nada perguntado a ninguém. CNPJ cifrado + HMACbanking
2. Iniciador provastartCompanyMemberProof { cpf } → rotas da PFRascunho open_account com company_member_id: dados, código, termos, documento + selfie. Termina em person → phone_possession → member_proven, sem conta PF e sem conferir titularbanking, people, biometrics
3. EmpresagetCompanyOnboarding (a leitura anda)Depois da prova, UMA vez: companies.registerCompany + consultRegistry (via bureaus, origem consulted). Situação ≠ ATIVA: REJECTED COMPANY_NOT_ACTIVE. Fora do ar: 503companies → bureaus
4. Quem representadeclareCompanyRepresentation { members }Lista inteira (o iniciador nela): papel, isolada/conjunta/none, jointN, alçada, vigência. Membro novo ganha convite (token de 32 bytes, só o SHA-256 guardado, 7 dias, devolvido UMA vez)banking
5. Convidado provaacceptCompanyInvite { token } → rotas da PFRascunho com o CPF DECLARADO: o documento tem de mostrá-lo. Reabrir o link devolve o rascunho em cursobanking, people, biometrics
6. DocumentosrequestCompanyDocumentUpload → PUT no endereço → attachCompanyDocument { documentId }O arquivo nasce AQUI para o caso (endereço de envio público, createFile com publicUpload); conferência ICP no e-signature contra o CNPJ e, no contrato social, cada sócio administrador provado (expectedSignerDocument); anexo no companies como UPLOADEDfile-storage, e-signature, companies
7. EnviosubmitCompanyOnboardingExige iniciador e convidados provados, contrato social, procuração se há procurador (ONBOARDING_INCOMPLETE com missing). Leva vínculo e poder DECLARADO ao companies (chave por membro, validFrom = nascimento do caso). Calcula os motivos de mesabanking, companies
8. MesaGET/POST /admin/company-onboardings…Fila, ficha (CPF mascarado, QSA, documentos com link, motivos; abrir deixa case.viewed), decisão sobre a VERSÃO lida. Aprovar: documento VALID e poderes conferidos no companies com o operador como ator, e evaluate precisa de combinação suficientebanking, companies
9. Abertura(segue a aprovação; a leitura retoma)company_holder_check → bank → company_account → signing_policy → demo_credit → enable, com vez (lease) e chave de idempotência do casobaas

Representação → política do baas. Com o portão que sabe quórum (enforcesQuorum, D-PJ2, baas README §14.9), a abertura grava a política QUORUM: cada membro das combinações suficientes do companies vira representante com a alçada (amountLimitCents), a vigência (validUntil) e o "conjunta, n" (minSigners), numa faixa de 1 assinatura que conta — e a conta abre sem pausa (quorumEnforced: true). Com um baas anterior ao quórum vale o caminho antigo: isolados sem alçada em operators; sem nenhum, os signatários conjuntos entram e a conta abre com pix.send e pix.keys pausados (quorumEnforced: false). Nunca NONE. O motivo de mesa QUORUM_NOT_ENFORCEABLE continua sendo levantado no envio (o envio não sabe qual baas vai abrir a conta); com o quórum no ar ele é só informativo.

Motivos de mesa (desk_reasons): POLICY_ALWAYS_DESK (toda PJ vai à mesa nesta fatia), REGISTRY_NOT_FOUND, QSA_UNAVAILABLE, OPENER_NOT_IN_QSA, MEMBER_NOT_IN_QSA, ADMIN_PARTNER_NOT_INVITED, DOCUMENT_SIGNATURE_INVALID, DOCUMENT_SIGNATURE_INDETERMINATE, SIGNER_MISMATCH, JOINT_REPRESENTATION, QUORUM_NOT_ENFORCEABLE, REGISTRY_FROM_MOCK (QSA do provedor de demonstração do bureaus; fora do MockBank a abertura recusa antes da conta). Só documento ANEXADO conta para o envio, e a aprovação marca VALID só o dossiê do envio (anexado até submittedAt). Aprovar exige uma pessoa (userId); chave de máquina recebe UNAUTHENTICATED. O QSA casa por nome normalizado E os seis dígitos visíveis do CPF mascarado (só nome dá falso positivo em homônimo).

Estados: DRAFT → COLLECTING ⇄ DECLARING → IN_REVIEW → APPROVED → OPENING → ACTIVE; REJECTED de qualquer um; devolvido volta a COLLECTING com a nota (cifrada; só no fio do iniciador). Membro: INVITED → PROVING → PROVEN | REJECTED. Vencido (30 dias sem passo) = desconhecido.

Corridas. Toda escrita que decide é condicional (estado + versão): duas declarações, dois envios, duas abas da mesa, dois aceites, duas réplicas abrindo — um vencedor. Dois casos da mesma empresa: o índice único parcial company_onboardings_holder_key (só na migration; db push não o cria) recusa o segundo com HOLDER_EXISTS. Testado em tests/unit/banking/company-onboarding.test.ts.

Trilha. banking.company_onboarding.{started, throttled, member.proof.started, member.proven, member.rejected, registry.read, registry.refused, representation.declared, invite.accepted, invite.refused, document.attached, document.refused, submitted, case.viewed, decision, decision.refused, opening.step, opening.failed, account.opened, rejected} e banking.onboarding.member.proven. Nunca CPF, CNPJ, nome, token de convite ou nota. Toda recusa de rota também passa pelo registro geral (§14.1), com caseId.

08

Conceitos e modelo de dados

Schema Postgres banking. Toda tabela carrega organization_id, subaccount_id e owner_id (subaccount_id ?? organization_id), e toda consulta entra pelo dono.

ModeloTabelaO que guarda
BankingAppAccountapp_accountsconta do baas habilitada no aplicativo (adesão explícita), estado e capacidades pausadas pelo servidor
BankingAppDeviceapp_devicesíndice aparelho → conta. A chave continua no baas
BankingAppSessionChallengesession_challengesdesafio de login, de uso único
BankingAppDeviceIdentityProofdevice_identity_proofsa selfie que provou o titular no primeiro login de um aparelho: marca o aparelho como conhecido e gasta a selfie (índice único)
BankingAppSessionsessionssessão: estado, geração corrente e canal (app ou desktop, com o pedido que a aprovou). O token não é guardado
BankingDesktopAccessRequestdesktop_access_requestspedido de acesso do computador: conta alcançada (ou nenhuma), número e opções, nonce, hash do token de consulta, estado PENDING → APPROVED | REFUSED | EXPIRED, quem decidiu e quando a sessão foi recolhida. Nunca o CPF (§7.3)
BankingAppPixIntentpix_intentso que foi mostrado e será assinado, o step-up decidido, a chave de idempotência e o desfecho
BankingCreditApplicationRequestcredit_application_requestspedido de análise com Idempotency-Key: a chave → o applicationId (o id da linha), a impressão das notas, de onde veio o primeiro pedido e quando o serviço respondeu pela primeira vez
BankingAppSettingssettingsregra do dono: "selfie sempre" e os limites do canal (selfieReasons/stepUpThresholdCents obsoletos, §7.1)
BankingAppStepUpPolicystep_up_policiespolítica de step-up por valor de um nível: a organização (target = 'organization') ou uma conta (target = accountId); NULL herda (§7.1)
BankingAppTenantHosttenant_hostshost público do aplicativo → organização: o escopo das rotas sem sessão
BankingAppProfileapp_profilesespaço do cms do manifesto e o canal do código do revínculo (template e remetente)
BankingAppAttestationNonceattestation_noncesnonce da atestação, de uso único
BankingAppOnboardingonboardingsrascunho de revínculo: a conta (só no servidor; null sem conta), o HMAC do código, tentativas e envios. Nunca o CPF
BankingCompanyOnboardingcompany_onboardingscaso de abertura PJ: CNPJ cifrado + HMAC, empresa e registro (cifrado), estado e versão, motivos e decisão da mesa, passos da abertura (§7.6)
BankingCompanyOnboardingMembercompany_onboarding_memberspessoa do caso: papel, modo, jointN, alçada, vigência, CPF e nome cifrados, HMAC do CPF (único no caso), hash do convite, rascunho de prova, pessoa, vínculo e poder no companies
BankingCompanyOnboardingDocumentcompany_onboarding_documentscontrato social ou procuração: arquivo (criado por este bloco), conferência ICP (pior caso e cada uma), documento no companies
BankingAppBiometricSessionbiometric_sessionsselfie aberta por este bloco: de quem (conta, rascunho, sessão ou aparelho do primeiro login), a que hash, se já foi gasta

Estados da intenção: PENDING → SENDING → COMPLETED | FAILED | UNKNOWN. SENDING → PENDING quando o portão do baas recusa antes de reservar (idempotência guarda só sucesso). UNKNOWN → COMPLETED | FAILED quando a consulta ao baas resolve.

Token de sessão. bks1.<a|r>.<sessão>.<geração>.<validade>.<hmac>. Assinado com BANKING_SESSION_TOKEN_SECRET, não é JWT e nenhum outro bloco o aceita. É determinístico por geração — o que permite devolver "o mesmo par novo" na tolerância sem guardar token.

09

Referência da API

Envelope: { "data": ... } / { "error": { "code", "message" } }, com message === code. data: null é resposta legítima; método void é 204 sem corpo. Opcional ausente é chave ausente, nunca null. Todo *Cents é inteiro; datas em ISO. Status por código espelha ORCHESTRATION_ERROR_STATUS; o que não está lá sai 422.

Aplicativo — prefixo /banking, autenticação pelo token de sessão (Authorization: Bearer).

MétodoRotaSessão
isDeviceBoundGET /v1/devices/:deviceId/boundnão
createSessionChallengePOST /v1/session/challengesnão
openSessionPOST /v1/sessionnão (assinatura no corpo)
refreshSessionPOST /v1/session/refreshnão (refresh no corpo)
closeSessionDELETE /v1/sessionopcional
getHomeGET /v1/homesim
getStatementGET /v1/statement?from&to&directionsim
getTransactionGET /v1/transactions/:idsim
getPixContactsGET /v1/pix/contactssim
lookupPixKeyPOST /v1/pix/keys/lookupsim
createPixIntentPOST /v1/pix/intentssim
sendPixPOST /v1/pix/transfers + Idempotency-Keysim
getPixLimitsGET /v1/pix/limitssim
getHoldsGET /v1/holds?status=active|endedsim
getHoldGET /v1/holds/:idsim
getLiensGET /v1/lienssim
getPixTransferByKeyGET /v1/pix/transfers/by-key/:idempotencyKeysim
getManifestGET /v1/manifestnão (host)
getCapabilitiesGET /v1/capabilitiesnão (host)
startOnboardingPOST /v1/onboarding { cpf }não (host)
getOnboardingGET /v1/onboarding/:onboardingIdnão (host)
sendOnboardingOtpPOST /v1/onboarding/:onboardingId/otpnão (host)
verifyOnboardingOtpPOST /v1/onboarding/:onboardingId/otp/verifynão (host)
createBiometricSessionPOST /v1/biometrics/sessionsopcional (sem ela: onboardingId ou deviceId)
getBiometricDecisionGET /v1/biometrics/sessions/:sessionId/decisionopcional
createAttestationNoncePOST /v1/devices/attestation-noncenão (host)
registerDevicePOST /v1/devicesopcional (sem ela: onboardingId)
getDevicesGET /v1/devicessim
revokeOtherDevicesPOST /v1/devices/revoke-others (204)sim
createDesktopAccessRequestPOST /v1/desktop/access-requests { cpf }não (host)
pollDesktopAccessRequestPOST /v1/desktop/access-requests/:requestId/poll { pollToken }não (host); aprovado grava o cookie
getPendingApprovalsGET /v1/approvalssim (app)
approveDesktopAccessPOST /v1/desktop/access-requests/:requestId/approve (204)sim (app) + assinatura
refuseDesktopAccessPOST /v1/desktop/access-requests/:requestId/refuse (204)sim (app)
signOutDesktopPOST /v1/desktop/sessions/revoke (204)sim (app ou computador)
createDesktopOperationPOST /v1/desktop/operations { kind, recipientKey, amountCents, description?, source, idempotencyKey } (201)sim (só computador)
getDesktopOperationGET /v1/desktop/operations/:requestIdsim (app ou computador)
cancelDesktopOperationPOST /v1/desktop/operations/:requestId/cancel (204)sim (app ou computador)
createDesktopOperationIntentPOST /v1/desktop/operations/:requestId/intent (201, PixIntent)sim (só app)
refuseDesktopOperationPOST /v1/desktop/operations/:requestId/refuse (204)sim (só app)
getDesktopSessionsGET /v1/desktop/sessionssim (app ou computador)
startCompanyOnboardingPOST /v1/company-onboarding { cnpj }não (host)
getCompanyOnboardingGET /v1/company-onboarding/:caseIdnão (host); posse = caseId + X-Banking-Case-Key
startCompanyMemberProofPOST /v1/company-onboarding/:caseId/opener { cpf }não (host); chave do caso
declareCompanyRepresentationPOST /v1/company-onboarding/:caseId/representation { members }não (host); chave do caso
requestCompanyDocumentUploadPOST /v1/company-onboarding/:caseId/documents/uploads { type, memberId?, fileName, contentType, sizeBytes }não (host); chave do caso
attachCompanyDocumentPOST /v1/company-onboarding/:caseId/documents { documentId }não (host); chave do caso
submitCompanyOnboardingPOST /v1/company-onboarding/:caseId/submitnão (host); chave do caso
acceptCompanyInvitePOST /v1/company-invites/accept { token }não (host)
createHandoffPOST /v1/handoffs { kind: 'company-case' | 'onboarding-draft', id, cpf? } (201, { token, expiresAt })não (host); company-case pede a chave do caso
consumeHandoffPOST /v1/handoffs/consume { token } (201, { kind, case | draft })não (host)

A posse do caso PJ (23/10). O caseId sozinho não dá mais posse: startCompanyOnboarding devolve UMA vez a caseKey, e toda rota do caso pede X-Banking-Case-Key (só o SHA-256 fica em company_case_keys). Sem chave, ou com a de outro caso: 404 COMPANY_ONBOARDING_UNKNOWN, igual a caso desconhecido, com banking.company_onboarding.key.refused na trilha. Caso sem chave (anterior às chaves) NÃO tem posse por id: é recusado (0 casos em produção em 23/10). O caso, o iniciador e a chave nascem numa transação. A chave vence depois de 30 dias sem uso (o uso estende); no desfecho (conta aberta ou recusa) as chaves de passagem são revogadas na hora e a de quem abriu/provou fica só para ler por 24 h.

A passagem para outro aparelho (23/10). O QR do computador leva um TOKEN (createHandoff), nunca o id: 10 minutos, uso único, preso ao alvo (caso PJ ou rascunho PF) e ao CPF de quem tem de provar. Gerar outro revoga o anterior. consumeHandoff gasta o token: o caso vem com uma chave PRÓPRIA do aparelho que resgatou (presa ao CPF de quem abriu: só lê e começa a prova com esse CPF). A prova guarda a chave que a começou (proof_key_id): SÓ essa chave vira posse inteira, e quando quem abriu prova, toda outra chave de passagem do caso é revogada (QR interceptado e resgatado não espera a prova do dono para herdar o caso) — COMPANY_ONBOARDING_PROOF_REQUIRED / HANDOFF_CPF_MISMATCH antes disso. O rascunho PF segue para documento e selfie; a troca reconfere a prontidão (código, termos, sem conta) e o CPF. Recusas: HANDOFF_UNKNOWN (desconhecido, revogado, alvo sumido), HANDOFF_USED, HANDOFF_EXPIRED, todas com banking.handoff.refused e o motivo. O rascunho PF resgatado vem com uma chave PRÓPRIA e limitada (abaixo).

A posse do rascunho de abertura PF (23/10). O onboardingId sozinho não dá posse de um rascunho open_account (abertura PF e prova de membro de caso PJ): startOnboarding (abertura), startCompanyMemberProof e acceptCompanyInvite devolvem UMA vez a draftKey, e as rotas do rascunho pedem X-Banking-Draft-Key (só o SHA-256 fica em onboarding_draft_keys): getOnboarding (ler), updateOnboarding, sendOnboardingOtp, verifyOnboardingOtp, submitConsents e createHandoff onboarding-draft (inteira), createBiometricSession e registerDevice sem sessão com onboardingId (prova). Sem chave, com a de outro rascunho, ou rascunho sem chave nenhuma (não há caminho legado): 404 ONBOARDING_UNKNOWN, igual a rascunho desconhecido, com banking.onboarding.key.refused (missing | mismatch | no_key | limited | proof_bound) na trilha. A chave do celular que resgatou a passagem é limitada: lê e faz documento + selfie, não troca dados, telefone, código nem termos. A PROVA fica presa à chave que a começou (onboardings.proof_key_id, escrita condicional; key.proof_bound na trilha); provado (a abertura começou) ou fechado, caem só as OUTRAS passagens (as que não fizeram a prova, key.revoked): a chave de quem criou (o computador) segue lendo e terminando o que não pede prova, e a da prova segue. Prova começada (chave da prova presa ou selfie aberta), os dados não mudam mais: updateOnboarding, sendOnboardingOtp (telefone) e submitConsents respondem 409 ONBOARDING_DATA_LOCKED, com banking.onboarding.data.locked (step, reason: proof_bound | selfie_started, fields sem valores) — a selfie aprova os dados que estavam ali; um computador roubado não troca o e-mail entre a selfie no celular e a aprovação. A escrita dos dados também é condicional (proof_key_id e biometric_session_id nulos). Fechado (concluído, recusado), as que sobraram só leem, por até 24 h contadas do fecho de verdade (completed_at ou rejected_at, nunca updated_at; reason: closed). O revínculo (bind_device) fica como está: o código vai ao telefone do cadastro.

Sessão do computador: cookie __Host-banking_desktop (sem Authorization). Só as rotas desktop do mapa (§7.3); o resto responde 403 CHANNEL_NOT_ALLOWED.

O cartão do favorecido (lookupPixKey e getPixContacts). bankLabel é SEMPRE o nome da instituição — pela lista de participantes do STR do Banco Central (@shared/utils/br-institutions, ISPB ou COMPE, gerada por scripts/generate-br-institutions.py), senão o nome que o provedor deu, senão "Outra instituição"; nunca vazio nem "Banco <código>". O documento sai SEMPRE mascarado (em claro, este bloco mascara; forma ilegível não sai). holderType (PF/PJ) vem do DICT ou do tamanho do documento mascarado. Nos contatos, documento, instituição e tipo vêm da intenção que originou o envio (o que o DICT disse na revisão, gravado mascarado): nenhuma consulta nova ao DICT por contato. Envio que não passou pelo aplicativo fica sem maskedDocument e sem holderType (campos opcionais, aditivos).

Escopo antes da sessão: o host. As rotas "não (host)" acham a organização pelo Host do pedido — ou pelo X-Forwarded-Host, SÓ quando o pedido vem de um proxy listado em BANKING_TRUSTED_PROXIES (o mesmo vale para X-Forwarded-For no limite por IP). Sem essa configuração, valem o Host e o IP do socket: quem chama não escolhe o próprio banco nem o próprio IP. O mapa host → organização é do operador da plataforma (PUT /admin/platform/hosts/:host): além da permissão, a organização do token tem de estar em BANKING_PLATFORM_ORGANIZATION_IDS — o iam não tem conceito de plataforma (o ADMIN de TODA organização tem toda permissão). Lista vazia ou ausente: 403 para todos, e o mapa se administra por migração/seed ou por quem tem acesso ao banco (banking.tenant_hosts). Host sem mapa: 404 TENANT_UNKNOWN. Nunca pelo corpo nem pelo caminho.

O que desligar um host faz, e o que não faz. Desligado, as rotas "não (host)" da tabela acima passam a responder 404 TENANT_UNKNOWN para aquele endereço: manifesto e capacidades, cadastro (PF e PJ), termos, o nonce de atestação e o pedido de acesso pelo computador. Não corta quem já tem aparelho vinculado: as rotas com sessão (abrir sessão, saldo, extrato, Pix, crédito) acham a organização pela sessão, não pelo host. Desligar não encerra conta nem revoga aparelho.

O proxy confiável, por ambiente (Launch). Staging e produção passam pela MESMA borda: o Traefik do LXC 105 (sp1-sd-catalisatec-1, IP público 177.104.184.38), que fala com as réplicas a partir de 2804:1740:f:2000::105 (IPv6) ou 172.10.0.105 (IPv4) — fonte: developy.cloud/apps/web/src/lib/server/ingress/traefik.ts, scripts/ops/ipv6-para-plataforma.sh:41, scripts/ops/traefik-host-mode.sh. Valor para os dois ambientes: BANKING_TRUSTED_PROXIES=2804:1740:f:2000::105,172.10.0.105. Com os padrões do Traefik (sem forwardedHeaders.trustedIPs) ele descarta o X-Forwarded-* do cliente e grava o IP do par TCP, então o X-Forwarded-For que chega é o do cliente. Sem esse valor, todo cliente atrás da borda cai no MESMO contador (o IP do socket é o da borda: 30/min para o banco inteiro). As réplicas têm IPv6 global e podem ser alcançadas sem a borda — por isso o cabeçalho só vale quando o par TCP É a borda. Conferido só no repositório: antes de ligar, confirmar no CT 105 que o compose não tem forwardedheaders.insecure/trustedIPs/proxyProtocol e segue em network_mode: host, no nó que o DNAT de :80/:443 não faz SNAT, e numa réplica que o IP remoto visto é ::105/.105.

Limite obrigatório. As rotas sem sessão (inclusive isDeviceBound, openSession e startOnboarding) exigem o limitador: com RATE_LIMIT_ENABLED=false ou Redis fora (erro ou sem resposta em 1 s), respondem 503 DEPENDENCY_UNAVAILABLE — fecham, nunca ficam ilimitadas. O RateLimiterService da plataforma falha ABERTO por padrão; o banking o chama em modo estrito (strict: true), que lança e cai no 503. Para o deploy: o padrão da plataforma nos dois stacks (deploy/docker-stack.{staging,prod}.yml, âncora app_env) e em scripts/envs/.env.{staging,production} é RATE_LIMIT_ENABLED=false; o serviço banking liga o dele (RATE_LIMIT_ENABLED: "true"). Um ambiente que monte o env do banking sem essa linha — o Launch lê o stack pelo launch-env.sh — fica com o login e o cadastro em 503.

O código sai fora da resposta. sendOnboardingOtp responde sempre do mesmo jeito (200, sentTo inteiro mascarado), com ou sem conta, e a entrega corre em segundo plano, com até 3 tentativas quando uma dependência não responde. Falha do baas, do people ou do canal no caminho com conta NÃO vira 503: vai para a trilha (banking.onboarding.otp.undelivered, com o motivo). O código só vai para telefone com posse provada por fluxo interno do people (código enviado a ele e confirmado; o HMAC do valor prende a prova ao número). verifiedAt/verificationId declarados por quem grava não contam. Sem posse provada, nada sai e a resposta é a mesma. A abertura de conta (§7.2) confirma a posse do telefone para onde o código dela foi; titular antigo, aberto antes dela, só recebe código com a marca de migração/seed (people.person_contacts.possession_verified_at e possession_value_hash). Só o canal da organização não configurado recusa (503 CAPABILITY_UNAVAILABLE), e igual para todo CPF.

Manifesto e capacidades não são deste bloco. O manifesto é o publicado no cms (espaço cmsSpaceKey do perfil; sem espaço, 404 NOT_FOUND), e o estado de cada capacidade vem do feature-flags (maintenance → maint). Fora do ar: 503 DEPENDENCY_UNAVAILABLE.

Os outros 21 métodos do contrato: 501 NOT_IMPLEMENTED (notificações, QR, chaves Pix, crédito, trilha, privacidade, risco). Caminho fora do mapa: 404 ROUTE_NOT_FOUND. O extrato e a lista de bloqueios cortados no teto avisam pelo cabeçalho X-Banking-Truncated.

Os três números da Home (balance), do getBalance do baas:

CampoContaPor quê
totalCentsavailable + blockedo que a pessoa tem
blockedCentsblocked + (available − spendable)banco (PROVIDER) + escriturado (LEDGER); cada bloqueio está em UMA parcela — com PROVIDER o banco já tirou do available
spendableCentsmax(0, spendable)o destaque; piso só para a tela
underReviewspendable < 0o alarme de divergência do baas; o app diz "em conferência"
unexplainedBlockedCentsmax(0, blockedCents − Σ ativos listados)bloqueio feito direto no banco; zero se a lista falhar ou vier cortada — nunca "nada tem motivo"

balanceCents continua por um ciclo, igual a balance.spendableCents. Conta sem bloqueio nenhum não consulta a lista (a Home fica idêntica à de antes).

A recusa explicada vai em error.refusal, fora de details — é o lugar que o cliente do aplicativo lê e confere:

OndeCódigonothingLeftTheAccountchallengeConsumed
intenção: valor > gastável e ≤ total, com bloqueioINSUFFICIENT_SPENDABLE_BALANCE (422)truefalse
envio: portão do baas recusa por saldo gastável (LEDGER)INSUFFICIENT_SPENDABLE_BALANCE (422)truetrue
envio: banco recusa com bloqueio ativo, valor > gastável e ≤ total (PROVIDER)INSUFFICIENT_SPENDABLE_BALANCE (422)truetrue
envio: portão de capacidade sem resposta, ausência da transação provadaCAPABILITY_UNAVAILABLE (503)truefalse

Fora disso, a recusa do banco continua PIX_REFUSED sem refusal (com details.balanceHeld e details.heldCents quando há bloqueio). No ramo de desfecho desconhecido não há refusal.

Operação — prefixo /banking/api/v1/admin, JWT ou chave de API da plataforma.

RotaPermissão
PUT /accounts/:accountId (habilitar, suspender, pausar capacidade)BAAS_ADMIN
GET /accounts/:accountIdBAAS_READ
POST /accounts/:accountId/devices/syncBAAS_ADMIN
GET /settings · PUT /settingsBAAS_READ · BAAS_ADMIN
GET /settings/step-up · PUT /settings/step-up { selfieAboveCents?, selfieNewRecipientAboveCents?, selfieAtNightAboveCents?, selfieCumulativeDayAboveCents?, selfieOnNewDevice? } — política do CLIENTE; o PUT substitui o nível, campo ausente/null herda da plataforma, {} volta tudo ao padrão; acima do teto da plataforma, 400BAAS_READ · BAAS_ADMIN
GET /accounts/:accountId/step-up · PUT (mesmo corpo, ao menos um campo) · DELETE (volta a herdar) — override da CONTA; só endurece (mais frouxo que a organização, 400)BAAS_READ · BAAS_ADMIN · BAAS_ADMIN
GET /hosts (os hosts desta organização)BAAS_READ
PUT /platform/hosts/:host { organizationId } · DELETE /platform/hosts/:host?organizationId=<uuid> (host público do aplicativo → organização; 409 se já for de outra). No DELETE, organizationId é o dono esperado e é obrigatório: só desliga se o host for dessa organização, numa instrução só, e responde 200 { data: { host, organizationId } } (o corpo é a prova de que o dono foi conferido). 409 com details.code: BANKING_HOST_OF_ANOTHER_ORGANIZATION se for de outra; 404 com details.code: BANKING_HOST_NOT_MAPPED se não for de ninguém; 400 sem o parâmetro, com ele malformado ou com qualquer outro parâmetro (?organizationid=, ?orgId=…) — não existe "tira de quem o tiver". Toda recusa de ligar ou desligar (400/403/404/409) sai como banking.host.map.refused / banking.host.unmap.refused no stream do operador (trilha de auditoria; nunca webhook de cliente) e no logoperador da plataforma: IAM_ORGANIZATIONS_UPDATE E organização do token em BANKING_PLATFORM_ORGANIZATION_IDS (vazio: 403 para todos)
GET /profile · PUT /profile { cmsSpaceKey, otp: { channel: 'whatsapp', senderId, templateName, languageCode } | null }BAAS_READ · BAAS_ADMIN
GET /company-onboardings?status=&limit= (fila da mesa PJ) · GET /company-onboardings/:caseId (ficha; deixa case.viewed com quem abriu)BAAS_READ
POST /company-onboardings/:caseId/decision { decision: approve|return|reject, version, note? } — sobre a versão lida (409 se mudou); só a organização principalBAAS_ADMIN
POST /opening-reviews/:biometricSessionId/applicant { reason } — o dossiê do solicitante para a mesa de revisão da selfie de abertura: nome, nascimento, e-mail, telefone e CPF mascarados, passo, estado (open/opening/opened/rejected/expired) e prazo do rascunho. Só pessoa (máquina: 403 review.person_required); rascunho de outra organização ou subconta: 404. Toda leitura publica banking.onboarding.pii.viewed (quem, por quê, QUAIS campos, nunca os valores) ANTES de responder; trilha fora do ar: 503 e nada sai. Recusa: banking.onboarding.pii.refusedBIOMETRICS_ADMIN

Só a organização principal escreve (decisão do dono, 28/09). Habilitar conta (PUT /accounts/:accountId), sincronizar aparelhos (POST /accounts/:accountId/devices/sync) gravar a regra (PUT /settings) e a política de step-up (PUT /settings/step-up, PUT/DELETE /accounts/:accountId/step-up) recusam escopo de subconta — chave de subconta ou X-Subaccount-Id — com 403 e details.code: "SUBACCOUNT_SCOPE_FORBIDDEN", o mesmo código de biometrics, webhooks-engine e people. A conta do baas é da organização: se a subconta pudesse habilitá-la, quem chegasse primeiro ficaria com a conta e com as suas regras de limite, e o dono verdadeiro receberia 409. As leituras (GET) seguem abertas ao escopo. As rotas do aplicativo não mudam: entram pelo token de sessão, não pela autenticação da plataforma.

Política de step-up por valor (30/09). As leituras devolvem o que o nível gravou, a regra antiga convertida (legacy), o padrão e o teto da plataforma e o que vale, com a origem de cada campo. Validação: inteiros ≥ 0 até o teto da plataforma (organização) ou até o valor da organização (conta), booleano em selfieOnNewDevice, nada a mais (400). Conta não habilitada nesta organização: 404. Escrita concorrente: 409.

jsonc
// PUT /banking/api/v1/admin/accounts/:accountId/step-up  { "selfieNewRecipientAboveCents": 20000 }
// (campos em centavos; os de cada nível omitidos aqui são null = herda)
{ "data": {
  "accountId": "…", "version": 1,
  "account":      { "selfieNewRecipientAboveCents": 20000, … },
  "organization": { "selfieAboveCents": 200000, … },
  "legacy":       null,
  "platform":     { "selfieAboveCents": 100000, "selfieNewRecipientAboveCents": 50000, "selfieAtNightAboveCents": 50000, "selfieCumulativeDayAboveCents": 100000, "selfieOnNewDevice": false },
  "ceiling":      { "selfieAboveCents": 500000, "selfieNewRecipientAboveCents": 500000, "selfieAtNightAboveCents": 500000, "selfieCumulativeDayAboveCents": 500000 },
  "effective":    { "selfieAboveCents": 200000, "selfieNewRecipientAboveCents": 20000, "selfieAtNightAboveCents": 50000, "selfieCumulativeDayAboveCents": 100000, "selfieOnNewDevice": false,
                    "sources": { "selfieAboveCents": "organization", "selfieNewRecipientAboveCents": "account", "selfieAtNightAboveCents": "platform", "selfieCumulativeDayAboveCents": "platform", "selfieOnNewDevice": "platform" } }
} }
// PUT /banking/api/v1/admin/accounts/:accountId/step-up  { "selfieNewRecipientAboveCents": 20000 }
// (campos em centavos; os de cada nível omitidos aqui são null = herda)
{ "data": {
  "accountId": "…", "version": 1,
  "account":      { "selfieNewRecipientAboveCents": 20000, … },
  "organization": { "selfieAboveCents": 200000, … },
  "legacy":       null,
  "platform":     { "selfieAboveCents": 100000, "selfieNewRecipientAboveCents": 50000, "selfieAtNightAboveCents": 50000, "selfieCumulativeDayAboveCents": 100000, "selfieOnNewDevice": false },
  "ceiling":      { "selfieAboveCents": 500000, "selfieNewRecipientAboveCents": 500000, "selfieAtNightAboveCents": 500000, "selfieCumulativeDayAboveCents": 500000 },
  "effective":    { "selfieAboveCents": 200000, "selfieNewRecipientAboveCents": 20000, "selfieAtNightAboveCents": 50000, "selfieCumulativeDayAboveCents": 100000, "selfieOnNewDevice": false,
                    "sources": { "selfieAboveCents": "organization", "selfieNewRecipientAboveCents": "account", "selfieAtNightAboveCents": "platform", "selfieCumulativeDayAboveCents": "platform", "selfieOnNewDevice": "platform" } }
} }

Mudança para integradores (29/09). Quem abria a selfie do primeiro login de aparelho novo DIRETO no biometrics e passava o id em openSession agora recebe 403 IDENTITY_NOT_PROVEN com details.reason: BIOMETRIC_SESSION_UNKNOWN: a selfie tem de ser aberta por este bloco (POST /v1/biometrics/sessions com deviceId), para ficar presa ao aparelho.

Códigos do cadastro de aparelho: SESSION_UNKNOWN (404), ATTESTATION_NONCE_UNKNOWN (404), ATTESTATION_NONCE_USED (409), ATTESTATION_NONCE_EXPIRED (410), ONBOARDING_UNKNOWN (404), OTP_NOT_VERIFIED (403), OTP_LOCKED (403), TENANT_UNKNOWN (404), DEVICE_ALREADY_REGISTERED (409), CONTACT_UNAVAILABLE (só na trilha: a resposta ao aplicativo é neutra). IDENTITY_NOT_PROVEN ganha os motivos BIOMETRIC_SESSION_UNKNOWN, ENROLLMENT_MISMATCH e BIOMETRIC_TEMPLATE_MISSING.

Códigos próprios (além dos do contrato): IDENTITY_NOT_PROVEN (403, o nome do servidor de exemplo, com details.reason), SIGNATURE_INVALID, SIGNATURE_MISSING, CHALLENGE_MISMATCH, AUTHORIZATION_UNAVAILABLE, STEP_UP_UNAVAILABLE, PIX_REFUSED, PIX_OUTCOME_UNKNOWN (504), PIX_PENDING_CONFIRMATION (409), DEPENDENCY_UNAVAILABLE (503), INSUFFICIENT_SPENDABLE_BALANCE (422, com refusal), CAPABILITY_UNAVAILABLE (503, com refusal), RATE_LIMITED (429), NOT_IMPLEMENTED (501).

10

Início rápido

bash
# 1. habilitar a conta (já com política HOLDER_SIGNATURE íntegra no baas)
curl -X PUT $BB/banking/api/v1/admin/accounts/$ACCOUNT -H "Authorization: Bearer $JWT" -d '{}'

# 2. no aplicativo: desafio, assinatura, sessão
curl -X POST $BB/banking/v1/session/challenges -d '{"deviceId":"'$DEVICE'"}'
curl -X POST $BB/banking/v1/session -d '{"deviceId":"'$DEVICE'","challengeId":"…","signature":"<ECDSA do nonce>"}'
# primeiro login deste aparelho: + "biometricSessionId":"<selfie aberta com {deviceId}, aprovada, há < 5 min>"
# sem ela: 403 IDENTITY_NOT_PROVEN
# 1. habilitar a conta (já com política HOLDER_SIGNATURE íntegra no baas)
curl -X PUT $BB/banking/api/v1/admin/accounts/$ACCOUNT -H "Authorization: Bearer $JWT" -d '{}'

# 2. no aplicativo: desafio, assinatura, sessão
curl -X POST $BB/banking/v1/session/challenges -d '{"deviceId":"'$DEVICE'"}'
curl -X POST $BB/banking/v1/session -d '{"deviceId":"'$DEVICE'","challengeId":"…","signature":"<ECDSA do nonce>"}'
# primeiro login deste aparelho: + "biometricSessionId":"<selfie aberta com {deviceId}, aprovada, há < 5 min>"
# sem ela: 403 IDENTITY_NOT_PROVEN

Revínculo de aparelho (quem já tem conta). Pré-requisitos do operador, uma vez por organização:

bash
# 3. o host público do aplicativo → a organização (OPERADOR DA PLATAFORMA)
curl -X PUT $BB/banking/api/v1/admin/platform/hosts/banco.cliente.com.br -H "Authorization: Bearer $JWT_PLATAFORMA" \
  -d '{"organizationId":"<org>"}'
# 4. o perfil: espaço do cms com o manifesto e o canal do código
curl -X PUT $BB/banking/api/v1/admin/profile -H "Authorization: Bearer $JWT" -d '{
  "cmsSpaceKey": "app-banco",
  "otp": { "channel": "whatsapp", "senderId": "<uuid do phone number no wpp-business>",
           "templateName": "account_access_code", "languageCode": "pt_BR" } }'
# 3. o host público do aplicativo → a organização (OPERADOR DA PLATAFORMA)
curl -X PUT $BB/banking/api/v1/admin/platform/hosts/banco.cliente.com.br -H "Authorization: Bearer $JWT_PLATAFORMA" \
  -d '{"organizationId":"<org>"}'
# 4. o perfil: espaço do cms com o manifesto e o canal do código
curl -X PUT $BB/banking/api/v1/admin/profile -H "Authorization: Bearer $JWT" -d '{
  "cmsSpaceKey": "app-banco",
  "otp": { "channel": "whatsapp", "senderId": "<uuid do phone number no wpp-business>",
           "templateName": "account_access_code", "languageCode": "pt_BR" } }'

Organização sem número próprio: o senderId pode ser o número de OUTRA organização, desde que a dona tenha concedido o template à organização do banco (POST /wpp-business/api/v1/template-grants, ver o README do wpp-business). A concessão cobre só aquele template de autenticação, é revogável e vai para a trilha das duas organizações; o banking envia do mesmo jeito.

O que o operador tem de cadastrar e aprovar na Meta antes do passo 4: um template de categoria AUTHENTICATION, idioma pt_BR, no número remetente conectado ao wpp-business, com o botão copiar código (OTP / COPY_CODE). O corpo do template de autenticação é o da Meta (*{{1}}* é seu código de verificação., com o aviso de segurança opcional e a validade de 5 min). O banking manda o código no corpo e no botão. O titular precisa ter telefone no people (contato PHONE) e cadastro facial ativo no biometrics (a abertura de conta com enrollOnApprove).

bash
# no aplicativo (Host: banco.cliente.com.br), sem sessão
curl -X POST $APP/banking/v1/onboarding -d '{"cpf":"52998224725"}'              # rascunho neutro
curl -X POST $APP/banking/v1/onboarding/$ONB/otp -d '{}'                         # código no WhatsApp do cadastro
curl -X POST $APP/banking/v1/onboarding/$ONB/otp/verify -d '{"code":"123456"}'
curl $APP/banking/v1/onboarding/$ONB                                             # agora com a conta
curl -X POST $APP/banking/v1/biometrics/sessions -d '{"flow":"AUTHENTICATION","purpose":"Vincular aparelho","operationHash":"<deviceEnrollmentHash(conta, chave)>","onboardingId":"'$ONB'"}'
curl -X POST $APP/banking/v1/devices/attestation-nonce
curl -X POST $APP/banking/v1/devices -d '{"publicKey":"…","keyLevel":"native","hardwareBacked":true,"attestation":{…},"enrollmentSessionId":"…","deviceLabel":"Galaxy S24","onboardingId":"'$ONB'"}'
# no aplicativo (Host: banco.cliente.com.br), sem sessão
curl -X POST $APP/banking/v1/onboarding -d '{"cpf":"52998224725"}'              # rascunho neutro
curl -X POST $APP/banking/v1/onboarding/$ONB/otp -d '{}'                         # código no WhatsApp do cadastro
curl -X POST $APP/banking/v1/onboarding/$ONB/otp/verify -d '{"code":"123456"}'
curl $APP/banking/v1/onboarding/$ONB                                             # agora com a conta
curl -X POST $APP/banking/v1/biometrics/sessions -d '{"flow":"AUTHENTICATION","purpose":"Vincular aparelho","operationHash":"<deviceEnrollmentHash(conta, chave)>","onboardingId":"'$ONB'"}'
curl -X POST $APP/banking/v1/devices/attestation-nonce
curl -X POST $APP/banking/v1/devices -d '{"publicKey":"…","keyLevel":"native","hardwareBacked":true,"attestation":{…},"enrollmentSessionId":"…","deviceLabel":"Galaxy S24","onboardingId":"'$ONB'"}'
11

Receitas

Consultar o desfecho depois de "não conseguimos confirmar". GET /v1/pix/transfers/by-key/<a mesma chave>: 200 com a transação quando ela existe (em qualquer estado), 404 NOT_FOUND quando está provado que nada saiu, 504 PIX_OUTCOME_UNKNOWN com retryAfterSeconds enquanto não se sabe. "Provado" é: o baas respondeu, a listagem pela marca da intenção veio completa e vazia, e o desafio venceu há mais que a tolerância. Com o baas fora, resposta ilegível ou listagem cortada, a resposta é 504, nunca 404.

A chave só é gravada na intenção quando o envio a reserva (claimForSend). Uma consulta que chega com o POST ainda em trânsito não acha linha pela chave — e isso não prova que nada saiu. O 404 é verdadeiro por construção: antes dele, o banking fecha as intenções PENDING da conta cujo desafio venceu há mais que a tolerância (FAILED/NOT_SENT, atualização condicionada e pelo relógio do banco) — e a reserva confere o prazo pelo mesmo relógio, então uma intenção é fechada ou reservada, nunca as duas. Depois ele conta as PENDING que sobraram, relê pela chave (a reserva pode ter chegado no meio: responde o desfecho) e, para chave = id da intenção, lê a intenção: PENDING → 504 PIX_OUTCOME_UNKNOWN com retryAfterSeconds; fechada ou reservada por outra chave → 404. Chave avulsa: 504 se havia PENDING na contagem, senão 404. A tolerância (BANKING_OUTCOME_GRACE_SECONDS) tem mínimo de 10 s.

Na trilha, o 404 leva a intenção e a chave. O 504 em polling publica um evento por intenção a cada 15 min (por réplica); os repetidos ficam no log.

Renovar a intenção em CHALLENGE_EXPIRED. Intenção nova, chave nova. É seguro porque a conferência do desafio acontece antes de qualquer chamada ao baas (há teste para isso).

12

Integração com outros building blocks

BlocoPor quêComoSe não responder
baasconta, saldo, extrato, DICT, desafio, chaves, envioIBaasFacadeDEPENDENCY_UNAVAILABLE — falha fechada
peoplenome do titular na sessãoIPeopleFacade.resolvePersonsessão abre com nome vazio (conteúdo)
audit-trailhash da linha da trilha do PixIAuditTrailFacade.latestEventresposta sai com trailHash: ""
biometricsselfie da operação; selfie do primeiro login; selfie do cadastro de aparelhoabrir e conferir pelo baas (openHolderBiometricSession, verifyHolderIdentity, enrollDeviceKey); a decisão por IBiometricsFacade.verifyEvidenceo portão recusa; login, selfie e cadastro recusam (DEPENDENCY_UNAVAILABLE)
baas (cadastro)conta pelo CPF do titular, selfie do titular, chave do aparelho, revogaçãofindHolderAccounts, openHolderBiometricSession, enrollDevice, revokeDeviceDEPENDENCY_UNAVAILABLE — nunca "não tem conta"
peopletelefone do cadastro para o códigoIPeopleFacade.resolveContactDEPENDENCY_UNAVAILABLE
wpp-businessentrega do código (template de autenticação)IMessagingFacade.sendWhatsAppTemplateDEPENDENCY_UNAVAILABLE
cmsmanifesto publicadoICmsFacade.getPublishedManifestDEPENDENCY_UNAVAILABLE
feature-flagsestado das capacidadesIFeatureFlagsFacade.getCapabilityStatesDEPENDENCY_UNAVAILABLE
serviço de crédito (CCP)notas, simulação, análise, proposta, aceite, operaçõesICreditFacade (HTTP, /credit-channel/v1)DEPENDENCY_UNAVAILABLE (dependency: credit); aceite sem resposta: CREDIT_OUTCOME_UNKNOWN
baas (crédito)CPF do titular, chave Pix de destinorevealHolderDocument, listPixKeysDEPENDENCY_UNAVAILABLE
13

Configuração e operação

Os limites se declaram. No boot, o serviço loga banking: limites do canal sem sessão com cada limite e a origem do valor: env (escolhido neste ambiente) ou default (padrão da casa, ninguém escolheu). Os padrões são para produção; ambiente com testadores reais na mesma rede (escritório, 4G com CGNAT: centenas de aparelhos atrás de um IP) estoura BANKING_ONBOARDING_RATE_LIMIT_PER_IP (10 em 10 min) e BANKING_PRESESSION_RATE_LIMIT (30/min) — o sintoma é 429 RATE_LIMITED no cadastro de quem nunca tentou antes. Suba os dois por env; o limite por telefone e o por CPF seguem segurando o abuso. Toda recusa vai para a trilha (banking.<assunto>.throttled).

VariávelSegredoPadrãoPara quê
BANKING_SESSION_TOKEN_SECRETsim— (mín. 32)assina o token de sessão. Sem ele o serviço sobe e nenhuma sessão abre. Não é o JWT_SECRET
BANKING_ACCESS_TOKEN_TTL_SECONDSnão600validade do token de acesso
BANKING_REFRESH_TOKEN_TTL_SECONDSnão43200validade do refresh (não é estendida na rotação)
BANKING_REFRESH_GRACE_SECONDSnão30tolerância do refresh anterior; 0 desliga
BANKING_SESSION_CHALLENGE_TTL_SECONDSnão120validade do desafio de login
BANKING_PRESESSION_RATE_LIMITnão30pedidos/minuto por IP nas rotas sem sessão
BANKING_STATEMENT_MAX_ROWSnão500teto de linhas lidas no baas por extrato
BANKING_OUTCOME_GRACE_SECONDSnão60 (mín. 10)espera, após o vencimento do desafio, antes de afirmar "nada saiu"
BANKING_TRAIL_WAIT_MSnão1500espera pelo hash da trilha no envio
BANKING_NEW_DEVICE_SELFIE_MAX_AGE_SECONDSnão300idade máxima da selfie do primeiro login de um aparelho
BANKING_STEPUP_SELFIE_ABOVE_CENTSnão100000 (R$ 1.000)padrão da PLATAFORMA: selfie em Pix acima deste valor (§7.1). Teto 100000000
BANKING_STEPUP_SELFIE_NEW_RECIPIENT_ABOVE_CENTSnão50000 (R$ 500)padrão da plataforma: selfie para favorecido novo acima deste valor
BANKING_STEPUP_SELFIE_AT_NIGHT_ABOVE_CENTSnão50000 (R$ 500)padrão da plataforma: selfie de noite acima deste valor
BANKING_STEPUP_SELFIE_CUMULATIVE_DAY_ABOVE_CENTSnão100000 (R$ 1.000)padrão da plataforma: selfie quando o enviado no dia + este Pix passa deste valor
BANKING_STEPUP_SELFIE_ON_NEW_DEVICEnãofalsepadrão da plataforma: true = aparelho novo pede selfie em qualquer valor
BANKING_STEPUP_MAX_SELFIE_ABOVE_CENTS · _MAX_SELFIE_NEW_RECIPIENT_ABOVE_CENTS · _MAX_SELFIE_AT_NIGHT_ABOVE_CENTS · _MAX_SELFIE_CUMULATIVE_DAY_ABOVE_CENTSnão500000 (R$ 5.000) cadateto até onde a ORGANIZAÇÃO pode afrouxar cada limite
BANKING_ATTESTATION_NONCE_TTL_SECONDSnão300validade do nonce de atestação
BANKING_ONBOARDING_TTL_SECONDSnão900validade do rascunho de revínculo
BANKING_OTP_TTL_SECONDSnão300validade do código
BANKING_OTP_MAX_ATTEMPTS / BANKING_OTP_MAX_SENDSnão5 / 3tentativas e envios por rascunho (reenviar não devolve tentativas)
BANKING_OTP_RESEND_SECONDSnão30espera entre envios
BANKING_ONBOARDING_RATE_LIMIT_PER_IP / _PER_DOCUMENTnão10 (10 min) / 5 (1 h)limite de startOnboarding por IP e por CPF (hash no Redis)
BANKING_OTP_RATE_LIMIT_PER_PHONE / _PER_PHONE_WINDOW_SECONDSnão5 / 3600envios do código por TELEFONE DE DESTINO, somados entre rascunhos, IPs e réplicas (chave otp-dest: = hash de organização + destino). Abertura: o número digitado — estourou, RATE_LIMITED com banking.onboarding.throttled (by: phone). Revínculo: a conta (o telefone do cadastro) — estourou, a resposta continua neutra e a trilha leva otp.undelivered (reason: PHONE_THROTTLED)
BANKING_ONBOARDING_MIN_RESPONSE_MSnão400tempo mínimo igual de startOnboarding, sendOnboardingOtp e createDesktopAccessRequest, com e sem conta
BANKING_DESKTOP_ACCESS_TTL_SECONDSnão120prazo para aprovar o acesso do computador
BANKING_DESKTOP_CLAIM_SECONDSnão60prazo para o computador recolher a sessão depois da aprovação
BANKING_DESKTOP_SESSION_TTL_SECONDSnão1800vida da sessão do computador (não renova)
BANKING_DESKTOP_IDLE_SECONDSnão300inatividade da sessão do computador, conferida no servidor
BANKING_DESKTOP_MAX_PENDINGnão3pedidos pendentes por conta; acima, o pedido nasce sem alcançar ninguém
CREDIT_SERVICE_BASE_URLp/ antecipação—base do serviço de crédito (o painel do ambiente: https://panel-api.stg.catalisa.io em staging, https://api.platform.catalisa.io em produção); sem ela a fachada não existe e o crédito responde DEPENDENCY_UNAVAILABLE
CREDIT_CHANNEL_SERVICE_KEYp/ antecipação—chave de serviço do canal; o MESMO valor no painel (SOPS dos dois lados)
CREDIT_SERVICE_TIMEOUT_MSnão45000teto por chamada; o aceite roda o workflow em linha no CCP
BANKING_CREDIT_ACCEPTANCE_REQUEST_TTL_SECONDSnão600vida do pedido de aceite feito no computador
BANKING_CREDIT_APPLICATION_KEY_TTL_SECONDSnão604800 (7 dias)janela em que a Idempotency-Key do pedido de análise repete o pedido; depois, 410 IDEMPOTENCY_KEY_EXPIRED. Máximo 29 dias: tem de ficar abaixo do tempo em que o canal de crédito lembra do applicationId (30 dias)
BANKING_CREDIT_ACCEPTANCE_REQUEST_MAX_PENDINGnão3pedidos de aceite pendentes por conta; acima, RATE_LIMITED
BANKING_PLATFORM_ORGANIZATION_IDSnão—organizações (uuid, vírgula) que mapeiam host pela API. Vazio: ninguém (403); mapa por migração/seed. A checagem de operador da plataforma é compartilhada (@shared/middleware/platform-operator.middleware) e o baas lê a MESMA variável, no serviço dele, para a concessão do provedor simulado
BANKING_TRUSTED_PROXIESnão—IPs (vírgula) dos proxies de borda cujo X-Forwarded-Host/X-Forwarded-For valem. Sem valor: Host e IP do socket
MODULE_BANKING_URL / MODULE_BANKING_PORTnão'' / 3040registro de módulos

Dependências de rede (standalone): MODULE_BAAS_URL, MODULE_PEOPLE_URL, MODULE_AUDIT_TRAIL_URL, MODULE_IAM_URL, e para o cadastro MODULE_BIOMETRICS_URL, MODULE_CMS_URL, MODULE_FEATURE_FLAGS_URL, MODULE_WPP_BUSINESS_URL.

O código é HMAC com BANKING_SESSION_TOKEN_SECRET: sem o segredo, nenhum código é emitido.

O que o nome do bloco não deriva e tem de ser literal: schema banking no schema.prisma, alias @banking/* no tsconfig.json e nas sete configs do vitest, dev:banking/start:banking no package.json (é de onde deploy/lxc/gerar-entrypoints.cjs gera a entrada do LXC), a entrada em scripts/build.mjs, o serviço nos dois deploy/docker-stack.*.yml (é de onde scripts/launch-env.sh lê o env) e a porta literal em main.ts (que o launch-env lê).

Migrations: prisma/migrations/20260927120000_banking_app_orchestration/, 20260928120000_banking_device_identity_proofs/, 20260929150000_banking_device_enrollment/ e 20260930120000_banking_step_up_policy/, 20261001120000_banking_desktop_access/, 20261001180000_banking_desktop_idle/, 20261012150000_banking_credit_application_requests/ — SQL cru, aditivas, idempotentes, só o schema banking.

14

Segurança e compliance

  • Só a organização habilita: as escritas de operação recusam subconta (403 SUBACCOUNT_SCOPE_FORBIDDEN, §9). Nenhuma subconta toma posse de conta da organização.

  • Escopo pela sessão, nunca pelo corpo nem pelo caminho. Antes da sessão, o escopo é descoberto pelo aparelho — e a resposta sem sessão é só sim/não.

  • Uso único no banco: desafio de login, rotação do refresh, tolerância e reserva do envio são updateMany condicionados ao estado anterior; zero linhas é recusa. Provado contra Postgres real em tests/integration/banking/repository-races.test.ts.

  • Reserva por conta sob pg_advisory_xact_lock: dois envios simultâneos que juntos estouram o limite do dia não passam os dois. Sob a mesma trava, no instante do envio, os limites por transferência que dependem do relógio ou do aparelho (noturno, aparelho novo) são refeitos: intenção das 19:59 enviada às 20:01 cai no limite noturno.

  • Idempotência por conta: a chave do aplicativo vira um UUID derivado de (conta, chave) para o baas (que é único por organização). Guarda só sucesso.

  • Falha fechada em todo caminho de dinheiro; bloco dependente fora do ar nunca vira "não" em isDeviceBound (o aplicativo apagaria o vínculo).

  • Nada sensível em log ou evento: nem token, nem assinatura, nem nonce, nem CPF, nem chave Pix. A chave do favorecido é apagada da intenção quando ela chega a estado final.

  • Refresh reapresentado fora da tolerância revoga a sessão inteira (REFRESH_REUSED).

  • Aparelho novo não entra só com a assinatura: o primeiro login exige selfie do titular com identidade provada, recente, de uso único e aberta para esse aparelho (§7).

  • Revínculo sem vazar o titular: a conta do rascunho mora só no servidor; o código é HMAC (nunca guardado), vai ao telefone do CADASTRO (nunca a um número vindo do aplicativo) e o captureToken da selfie só aparece na resposta. Nenhum evento carrega CPF, telefone, código ou token. O uso único é o índice do banco, provado contra Postgres real (duas réplicas com a mesma selfie: uma entra).

  • O computador nunca move dinheiro. A sessão desktop passa só nas rotas de leitura marcadas no mapa; dinheiro, assinatura, cadastro de chave e aprovação respondem CHANNEL_NOT_ALLOWED, no roteador e de novo no serviço. Provado chamando cada rota direto com o cookie (tests/unit/banking/desktop-access.test.ts); as transições do pedido, contra Postgres real (tests/integration/banking/desktop-access-races.test.ts).

14.1 Auditoria: toda operação, com sucesso ou recusa

Regra do dono (29/09): "temos que ter log e auditoria de tudo, não dá pra não ter". A origem foi um Pix de R$ 150 com R$ 100 livres recusado em createPixIntent sem deixar NADA: nem evento, nem linha, nem log.

  • Toda resposta de erro de um método do mapa vira evento e log. O envelope entrega o erro ao registro (services/refusal-audit.service.ts), que o roteador chama em volta de todas as rotas — inclusive as recusas que nascem antes do serviço (sem sessão, corpo inválido, limite de taxa) e as EXCEÇÕES que escapam do envelope (viram INTERNAL, internalCode: UNCAUGHT_EXCEPTION, failed).
  • Nome: banking.<assunto>.<desfecho>. O assunto de cada método está em AUDIT_SUBJECTS (routes/route-map.ts); método implementado sem assunto não sobe. O desfecho é refused (4xx), throttled (RATE_LIMITED), unknown (PIX_OUTCOME_UNKNOWN) ou failed (5xx). Exemplos: banking.pix.intent.refused, banking.pix.transfer.unknown, banking.session.refused, banking.statement.refused, banking.onboarding.start.throttled.
  • Um fato, um evento. Onde o serviço já publica a recusa com o evento dele (session.refused de assinatura e de identidade, session.revoked do refresh reapresentado ou do aparelho sumido, device.refused, pix.transfer.refused/.failed/.unknown, onboarding.throttled, onboarding.otp.locked), ele marca o erro (auditedError) e o registro só loga. O Pix segue a mesma régua do nome: resposta 5xx é pix.transfer.failed. Os assuntos de openSession, registerDevice e sendPix são os mesmos desses eventos.
  • O que o evento leva: method, code, status, reason (o motivo da refusal, o do provedor ou o código), a conta, a sessão e o aparelho, a intenção e o hash da operação quando existem, o valor em centavos (Pix), os números da recusa explicada e, das details, só uma lista fechada (capability, dependency, fields...), e só com forma de identificador: texto livre (que pode carregar um CPF) não sai. Todo id é conferido como UUID antes de sair.
  • De quem é a recusa sem sessão: a organização é a do HOST. O aparelho apresentado só acrescenta a conta quando é da mesma organização; aparelho de outra organização fica fora (nem conta, nem aparelho) — um id forjado não escreve na trilha alheia. Sem host que resolva, só o log.
  • O que NUNCA leva: CPF, telefone, chave Pix, token, assinatura, código, chave pública, selfie. O registro lê só as pistas tipadas (AuditHints), nunca o corpo; há teste varrendo trilha e log.
  • Log estruturado (pino) com os mesmos campos: warn para refused/throttled, error para failed/unknown.
  • A trilha nunca derruba nem atrasa a resposta. O roteador dispara o registro sem esperar (flushBankingAudit existe só para teste); falha ou demora (teto de 2 s por ida) vai para o log em error. Sem organização conhecida (host não mapeado), a trilha não tem onde gravar: fica o log, em warn.
  • Na trilha (audit-trail, utils/event-mapper.ts): refused é ACCESS_DENIED, throttled e banking.*.locked são RATE_LIMITED; o recurso é a intenção (banking_pix_intent), a sessão (banking_session), o rascunho (banking_onboarding) ou a conta (banking_account) — nunca mais a organização.
  • Catraca: tests/unit/banking/refusal-audit.test.ts percorre o mapa de rotas, força recusa (4xx), falha (5xx) e recusa antes do serviço em CADA método e exige exatamente um evento. Método novo com corpo precisa de uma fixture lá, ou o teste reprova.
  • Fora do registro: rota fora do mapa (404) e método não implementado (501) não são operação — só log em info.
15

Limitações conhecidas

  • Só 49 dos 61 métodos. QR, chaves Pix, vitrine, trilha, privacidade: 501.
  • Antecipação: a seleção de notas é só da estimativa. A análise do CCP considera todas as notas pendentes do cedente; a proposta diz quais entraram (decisão D4 do plano).
  • Antecipação: o "crédito na conta" é registrado na LEITURA (app, desktop ou console abrindo as operações), não por evento do serviço. Até alguém ler, credited_at fica vazio. Notificação ativa é frente do bloco notifications, que não existe.
  • Antecipação: chave de serviço por ambiente (CREDIT_CHANNEL_SERVICE_KEY) com a organização em cabeçalho — quem tem a chave fala por qualquer organização no CCP. Aceito para chamada interna (decisão D1); chave de máquina por organização quando entrar o segundo serviço de crédito.
  • Só 45 dos 66 métodos. Antecipação, QR, chaves Pix, trilha, privacidade: 501.
  • Abertura PJ: quórum e alçada não chegam ao portão. O baas só sabe OPERATOR (qualquer um da lista, sem teto). Representação conjunta ou com alçada abre com pix.send/pix.keys pausados (quorumEnforced: false). Fatia 2: quórum n de m e teto por operador no portão (D-PJ2).
  • Abertura PJ: a conta aberta ainda não é servida ao aplicativo — a sessão exige HOLDER_SIGNATURE e aparelho HOLDER; aparelho e sessão de representante são a fatia 2.
  • Abertura PJ: toda empresa vai à mesa (POLICY_ALWAYS_DESK) até a política de aceitação existir no decision-engine (D-PJ3).
  • Abertura PJ: sócio que já tem rosto cadastrado tem a selfie ONBOARDING recusada (subject.anchor_exists) e o rascunho fecha; a prova por AUTHENTICATION é a fatia 1b. O convite volta só ao iniciador (o envio pelo WhatsApp é a fatia 1b). O QSA real depende de provedor (o BigDataCorp não entrega owners por contrato: QSA_UNAVAILABLE, D-PJ1). O tamanho declarado do documento (até 10 MB) não é imposto pelo endereço de envio do armazenamento.
  • A aprovação do computador pode ser "pescada" (como a do WhatsApp Web): o golpista abre o computador dele e convence a vítima a aprovar. O número de conferência e o aviso no celular reduzem, não eliminam. O que protege o dinheiro é o canal: a sessão desktop só lê. Na fatia 1, o golpista veria saldo e extrato — privacidade, não dinheiro. Sem push: o celular só vê o pedido quando a pessoa abre o app (o bloco notifications não existe). O rótulo do navegador é o que o computador declarou; cidade aproximada não há (sem base de geolocalização por IP).
  • Abertura de conta sem KYC de bureau. A conta abre com o documento conferido pelo biometrics (CPF impresso lido e igual, ou o modo demonstração declarado), código no telefone e termos. Nada de listas restritivas nem de situação do CPF na Receita nesta fatia.
  • Oráculo de CPF na abertura (limite aceito). Depois do código e dos termos, a abertura recusa quem já é titular (HOLDER_EXISTS, antes da câmera) ou já tem rosto cadastrado (SUBJECT_ALREADY_ENROLLED): quem chega até ali descobre que o CPF é cliente. Fora do modo demonstração isso exige o documento com aquele CPF; no modo demonstração, só o telefone — aceito para demonstração, com limite por IP e por CPF e cada recusa na trilha.
  • Âncora de demonstração só abre conta no MockBank. Com a configuração padrão do baas em banco real, a abertura com âncora demo é recusada (DEMO_ANCHOR_REAL_BANK); selfie de sandbox nunca abre conta (SELFIE_NOT_LIVE). Os passos da abertura escrevem só com a VEZ (lease renovado a cada passo) e com o código conferido para o telefone de agora; o crédito de demonstração leva a chave de idempotência da abertura.
  • Os termos vêm do manifesto publicado. Sem legalDocuments no manifesto do cms, a abertura para no aceite (503 CAPABILITY_UNAVAILABLE, com log) — nunca um termo inventado.
  • startOnboarding ainda separa "cliente" de "não cliente" por dois caminhos estreitos. A resposta é neutra (mesmo rascunho, accountId vazio até o código, sentTo inteiro mascarado, o mesmo tempo mínimo, a entrega fora da resposta) — mas o tempo mínimo cobre variação comum, não medição fina de muitas amostras. Contido pelo limite por IP e por CPF, com evento na trilha (banking.onboarding.throttled). Não há limite por aparelho: antes do cadastro o aparelho não tem identidade. A intenção vem do aplicativo (purpose: 'open_account' ou ausente = revínculo); a abertura NÃO consulta o baas no começo — quem já é titular só descobre no fim, depois do código, dos termos e da selfie com o documento dele (HOLDER_EXISTS, na trilha).
  • Revínculo só enxerga contas da ORGANIZAÇÃO. O host aponta para a organização, e o rascunho procura conta habilitada no escopo dela (dono = organização). Conta habilitada por subconta — que a administração hoje nem permite — não seria achada pelo CPF. Um banco que opere por subconta precisa de host por subconta, que não existe nesta fatia.
  • Aviso ao titular de aparelho novo vinculado ainda não sai. O evento existe (banking.device.registered, com via e o aparelho) e é o gancho da notificação; o envio ao titular (WhatsApp/push nos outros aparelhos) é frente própria.
  • CPF titular de mais de uma conta habilitada não revincula pelo aplicativo (o rascunho nasce sem conta e nunca vincula; a trilha marca ambiguous). O aplicativo não escolhe a conta de ninguém.
  • Aparelho espelhado pela sincronização não tem rótulo nem nível: getDevices o mostra como "Aparelho", nível software (a direção que não promete chip). Cadastrado por registerDevice, sai com o que o aparelho declarou.
  • attestation é REGISTRADA e NÃO conferida. O nonce é deste servidor, de uso único e com validade; o token do Play Integrity / App Attest é guardado como "enviado" (unverified), nunca verificado. Aparelho com app adulterado passa por ela; a selfie do titular e a assinatura com chave de hardware são o que segura hoje. No login, attestation segue aceita e ignorada.
  • A selfie do primeiro login é presa ao aparelho, não ao desafio. Ela é aberta com deviceId e openSession só a aceita desse aparelho; mas ela não carrega o hash DESTE desafio. Dentro do prazo e sem uso, abriria qualquer login desse aparelho — que é o dono dela.
  • Chave cadastrada e revogação que falhou. Se a prova de identidade perder a corrida depois de a chave ir para o baas e a revogação compensatória também falhar, a chave fica ativa sem prova: o primeiro login desse aparelho pede selfie de novo (a regra de aparelho novo é a prova, não a chave).
  • Prova gravada e sessão não criada. Se a gravação da sessão falhar depois de a selfie ser gasta, o aparelho fica conhecido sem sessão aberta; o próximo login entra só com a assinatura. A identidade foi provada para aquele aparelho — é a direção que não abre nada a mais.
  • Falta de saldo não é conferida na intenção; saldo BLOQUEADO é. Valor acima do que a pessoa tem segue e quem recusa é o banco. Valor acima do gastável e dentro do total volta explicado antes da revisão — mas isso não substitui o portão: o bloqueio pode nascer entre a revisão e o envio, e aí a recusa vem no envio. Saldo ilegível na intenção não bloqueia: ela segue.
  • A recusa do banco com custódia PROVIDER é INFERIDA. O erro do banco não diz "saldo"; o banking lê o saldo depois da recusa e, com bloqueio ativo e o valor entre o gastável e o total, explica como bloqueio. Um banco que recusasse por outro motivo nessa mesma faixa seria explicado como bloqueio. Nada foi provado em aparelho nem contra sandbox com balanceHold: 'NATIVE'.
  • Bloqueios: uma chamada ao baas por bloqueio para ler o razão, uma de cada vez (paginação paralela já derrubou bloco por memória). O teto é 200 bloqueios por resposta. Razão ilegível degrada: o bloqueio sai sem as linhas. A lista inteira fora responde DEPENDENCY_UNAVAILABLE.
  • O rótulo depende de externalReference curta. Referência com espaço, longa ou com forma de UUID fica fora da frase ("Reservado para uma antecipação"). Texto livre (reasonText) com vocabulário do bloco ou forma de código é descartado e a frase padrão toma o lugar.
  • Início sem notificações: unreadNotifications: 0. anticipationAvailableCents vem do serviço de crédito e cai para zero (sem derrubar a Home) quando a antecipação está desligada ou fora.
  • trailHash pode vir vazio quando a trilha ainda não gravou a linha (ela é assíncrona).
  • Contatos vêm do histórico do baas (últimos 180 dias, 300 envios), não de uma agenda.
  • requireBiometric não é conferido em conta NONE pelo baas. O portão só roda com política de titular; numa conta NONE o campo é ignorado em silêncio. O banking recusa conta sem HOLDER_SIGNATURE íntegra na intenção e no envio, mas a política pode mudar entre essa leitura e a chamada ao baas — nessa janela a selfie decidida aqui não seria conferida. Fechar isso é do baas (recusar requireBiometric sem portão), fora desta fatia.
  • "Nada saiu" só com a listagem completa. O desfecho de um envio é buscado no baas pela marca da intenção (filter[businessId]). Consulta que falha, resposta ilegível ou listagem cortada no teto mantêm PIX_OUTCOME_UNKNOWN — e o bloqueio de intenção nova — até o baas responder por inteiro. Um baas anterior ao filtro devolve a janela toda; com mais de 1000 envios nela desde o Pix em dúvida, o desfecho fica desconhecido até a conciliação.

O que o servidor de exemplo do aplicativo fazia e este não reproduz: recusava por saldo já na intenção (INSUFFICIENT_FUNDS); devolvia antecipação disponível e uma notificação não lida de fixture; gravava leitura de extrato na trilha (statement.view); não tinha tolerância no refresh; e sempre devolvia trailHash, porque inventava a própria trilha. (A selfie no login de aparelho novo, com IDENTITY_NOT_PROVEN, o exemplo tinha e este bloco agora tem — conferida de verdade, contra o titular, em vez de só olhar o fluxo da sessão.)

16

Perguntas frequentes

Por que o banking não verifica a assinatura do Pix? Porque o portão do baas verifica, e uma segunda verificação é o segundo lugar a divergir. O banking confere tudo que é dele antes (desafio da intenção, prazo, aparelho, presença da selfie) e deixa a criptografia da operação para quem move o dinheiro.

Por que a selfie exigida pelo banking é conferida pelo baas? Para não reimplementar a conferência da evidência (vínculo ao hash, titular, identidade provada, ambiente). O campo requireBiometric só eleva a exigência.

E se o baas for de uma versão anterior? A política não traz callerStepUp, e a selfie decidida aqui é recusada com STEP_UP_UNAVAILABLE antes de ser pedida.