Skip to content

fix(braspag): Let Cielo merchants pay by credit card with 3DS in production - #867

Open
vitorrgg wants to merge 3 commits into
mainfrom
fix/braspag-cartao-cielo
Open

vitorrgg wants to merge 3 commits into
mainfrom
fix/braspag-cartao-cielo

Conversation

@vitorrgg

Copy link
Copy Markdown
Member

First credit card test on a Cielo merchant in production (Bom Ar Condicionado, is_cielo, required 3DS since v2.65.0). The card never got to the transaction. Each fix below was confirmed in the buyer's browser on production before being written here (console overrides on the live checkout).

What failed and what changed

  1. SOP posted the card to Braspag. The Cielo SOP script decides the endpoint by provider and defaults to Braspag (www.pagador.com.br), which answers 500 without CORS headers to a Cielo merchant with a real card. The checkout only showed "invalid card".
    • The card client sets provider: 'cielo' when the Cielo SOP script is on the page. list_payments loads it only for is_cielo in production. No new window variable, so stores still on the onload expression of v2.65.0 are fixed through the CDN (@2).
  2. 3DS refused with 102 "Invalid field value" before reaching the bank. Fields out of format:
    • bpmpi_merchant_url was empty, because storefront.settings is empty on the v3 checkout (/app/). It falls back to the page origin.
    • Phone without country code: it now gets 55 + area code + number, or is left out.
    • Empty fields (city, account dates…) are left out instead of sent empty.
    • bpmpi_device_1_provider: 'clearsale' is not an MPI provider (only cardinal, inauth, threatmetrix), so bpmpi_device_1_* is removed. The ClearSale session ID is for the antifraud.
    • Also per the docs: zip code with 8 digits, CPF/CNPJ as digits, street + number on line 1 and complement + borough on line 2, and the default address.
  3. The SOP access token is single use, but list_payments shared it for 2 minutes through Firestore (braspagAdmin/sop_*). Another buyer, or a reload, got a spent token and the same 500.
    • Every list_payments gets a new token. The OAuth token behind it stays cached. The docSOP.delete() calls in create_transaction are gone.
    • The checkout keeps the page's token across attempts, so a second attempt (e.g. after a failed 3DS challenge) is stopped before reaching the API and asks the buyer to reload. The 3DS refusal message (REFUSAL_MESSAGE, kept in sync with the client) now says the same.
  4. The CVV never reached the SOP. The class was bp-sop-cardcvvc, and a missing quote in the form HTML swallowed that input. This was left from feat(braspag): Require 3DS on credit card purchases and show the result on the order #855 for its own PR. The SOP form is now built with DOM inputs, which also avoids breaking on quotes in the holder name.

Tests

  • Production, buyer browser (Mastercard, real card), with the fixes applied through the console: the card went to the Cielo SOP (201), the 3DS challenge opened and returned authenticated (ECI 02, 3DS 2.2.0), and create_transaction was called with ExternalAuthentication.
  • Production, headless browser on the store domain, with this branch's minified card client (Cielo test card, fake 3DS token):
    • SOP request to transaction.cieloecommerce.cielo.com.br → 201, now with SecurityCode;
    • the 3DS form with incomplete customer data (phone with mask, zip with hyphen, empty city) → only valid fields, merchant_url from the origin;
    • a second attempt with the same token → rejected with the reload message, no request to the SOP.
  • Unit: node --test packages/apps/braspag/tests-unit/ → 19 passing.

Not fixed here

Cielo then refused the transaction with ReturnCode 002 "Credenciais Invalidas" (Status 0, no TID). Test-card authorizations without 3DS and without SOP get the same code. It is the merchant's enablement at Cielo, not code, and the merchant is handling it with Cielo support.

🤖 Generated with Claude Code

…uction

Tested on a Cielo merchant in production (Bom Ar), the card never reached
the transaction:

- The SOP script posted the card to Braspag, which answers 500 to Cielo
  merchants. The card client now sets `provider: 'cielo'` when the Cielo SOP
  script is on the page, which `list_payments` loads only for `is_cielo` in
  production, so stores on previous releases are fixed by the CDN script.
- The 3DS MPI refused with 102 "Invalid field value": empty
  `bpmpi_merchant_url` (`storefront.settings` is empty on the v3 checkout),
  phone without country code, empty fields and `clearsale` as device
  provider. Only values in the documented format are sent now.
- The SOP access token is single use, but `list_payments` shared it for two
  minutes through Firestore: another buyer or a reload got a spent token
  (500). Each `list_payments` gets a new one, and a second attempt on the
  same page asks the buyer to reload instead of a generic card error.
- The CVV was never sent to the SOP (`bp-sop-cardcvvc` class and a broken
  quote in the form HTML). The form is built with DOM inputs now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vitorrgg

Copy link
Copy Markdown
Member Author

Revisão adversarial

Escopo

O PR deveria: fazer cartão + 3DS funcionar para lojistas Cielo em produção (provider do SOP, campos do MPI, token SOP single-use, classe do CVV). Não deveria mudar: fluxo Pix/boleto, contrato browser↔servidor do hash (token, fingerPrintId, status3ds, out3ds), nem comportamento de lojistas Braspag não-Cielo. Os 5 arquivos tocados ficam todos em packages/apps/braspag + README — escopo limpo, sem colateral fora do app.

Referência comparada: o app legado (hosting/card-client.js, citado nos headers), os commits da área (#855, aa259e0, 2f4df15) e o padrão de cache Firestore que permanece em admin/get-auth.mjs e 3ds/get-3ds-token.mjs — a remoção do cache só no token SOP é coerente, porque só ele é single-use; o OAuth por trás continua cacheado.

Verificado localmente (worktree do branch): 19/19 testes passando, ESLint sem erros novos (2 warnings quote-props pré-existentes em linhas não tocadas), minificação dos assets roda e gera os .min.js (crítico, pois o CDN serve braspag-card-client.min.js gerado no build), CI verde.

Achados

Critical

Nenhum. Pontos centrais refutados sem sucesso: o default provider: 'braspag' preserva lojistas não-Cielo; onInvalid corretamente não marca o token como gasto (a requisição não saiu); campos booleanos false continuam sendo enviados (o filtro só pula undefined/null/''); db em braspag-create-transaction.mjs ainda é usado (QR code Pix); a troca de innerHTML por inputs DOM elimina de quebra a injeção de HTML via nome do portador.

Required

R1. Zero teste automatizado para a lógica nova do card client — assets/braspag-card-client.js ganhou três comportamentos críticos (formatação/omissão de campos 3DS, getSopProvider, guarda de token usado) e nenhum entrou em tests-unit/. A infra já existe: js-client.test.mjs já minifica o asset com uglify e avalia em vm. Cenário de falha: um ajuste futuro volta a enviar campo vazio (102 de novo) ou quebra o seletor do getSopProvider (500 de novo), e só um cartão real em produção detecta. Teste sugerido: avaliar o asset minificado em vm com document/window fake e afirmar (a) telefone vira 5511... e com máscara/9 dígitos é omitido, CEP só com 8 dígitos, nenhum bpmpi_device_1_*, nenhum input com value=""; (b) provider === 'cielo' sse existe script transaction.cieloecommerce.cielo.com.br; (c) segunda chamada com o mesmo _braspagAccessToken rejeita sem chamar bpSop_silentOrderPost; (d) o asset contém REFUSAL_MESSAGE verbatim — hoje os dois comentários "keep in sync" dependem de disciplina humana.

R2. Retry de 429 rejeita antes de tentar — sop/get-access-token.mjs:31-36: no catch, setTimeout(() => request(true), 7000) é agendado e reject(err) roda imediatamente — o retry nunca salva a promise (já rejeitada) e ainda dispara uma requisição extra descartada. O padrão é pré-existente (igual em create-auth.mjs), mas este PR tornou o endpoint quente: todo list_payments agora minta um token SOP (antes, 1 a cada 2 min por lojista). Cenário de falha: burst de cotações → Braspag responde 429 → sopResult rejeita → cartão some do checkout naquela cotação, intermitente e invisível para o lojista. Fix pequeno (mover o reject para o else) ou remover o retry morto. Teste: mock do axios com 429 na 1ª chamada e 200 na 2ª, afirmando que a promise resolve.

Optional

O1. bpmpi_merchant_newcustomer invertido — customer.orders?.length > 1 marca cliente recorrente como "novo" e cliente de primeira compra como existente. Pré-existente, mas o PR auditou campo a campo exatamente esse formulário, e isso alimenta o risco do emissor (mais fricção no challenge para quem menos merece). Deveria ser algo como !(customer.orders?.length >= 1).

O2. Cielo em sandbox fica com provider: 'braspag' — o seletor só casa o host de produção; o script sandbox da Cielo é transactionsandbox.pagador.com.br, indistinguível do Braspag. Se o sandbox Cielo exigir provider: 'cielo', teste em sandbox de lojista Cielo continua quebrado. O README restringe a afirmação a produção, então está documentado — mas fica sem cobertura.

O3. Skew de versão: a mensagem "recarregue" mente em loja com servidor antigo — o client novo chega via CDN @2 a lojas ainda em ≤2.65.0, cujo servidor segue cacheando o token SOP no Firestore por 2 min. Comprador B pega token já gasto → toast manda recarregar → o reload reserve o mesmo token cacheado por até 2 min. Não é regressão (antes era 500 mudo), mas é um loop de suporte conhecido até a loja atualizar o app.

Nit

  • Docs braspagAdmin/sop_* órfãos no Firestore (ninguém mais lê/escreve/deleta) — limpeza eventual.
  • onError marca o token como gasto mesmo em falha puramente de rede (offline/DNS), forçando reload de um token possivelmente intacto — indistinguível do 500 sem CORS no XHR, trade-off conservador aceitável.
  • bpmpi_billto_customerid aceita documentos de 12–13 dígitos (nem CPF nem CNPJ); length === 11 || length === 14 seria exato.

Eixos sem achados

  • Regressão: caminho antigo Braspag não-Cielo preservado pelo default do provider; Pix/boleto intocados; contrato do hash intocado (fingerPrintId removido só do objeto interno card3ds, que não o usava mais). Testes do caminho antigo existem (policy/payload) e passam.
  • Auth: tokens não vazam em log nem URL; OAuth segue cacheado; falha de token SOP degrada para "cartão não listado" em vez de resposta inválida.
  • Fila/concorrência: o fix melhora concorrência real (dois compradores na janela de 2 min não disputam mais o mesmo token); custo é 1 POST accesstoken por cotação — ver R2.
  • Integração: somatório por item do carrinho 3DS inalterado; Math.round(price * 100) correto para centavos.

Veredito: APROVAR COM RESSALVAS

As ressalvas são R1 (testes de regressão do card client — a infra já existe, é barato) e R2 (retry quebrado num endpoint que o próprio PR tornou por-cotação). Nenhuma bloqueia: o caminho feliz e os caminhos de falha principais foram verificados em produção com cartão real segundo a descrição do PR.

Ficou sem verificar: comportamento num lojista Braspag não-Cielo real com o client novo (a premissa "token SOP é single-use" foi generalizada a partir do caso Cielo — se para Braspag puro ele fosse reutilizável, a guarda do client força reload onde antes retry imediato funcionava); sandbox Cielo (O2); se todos os checkouts (v2 legado e v3 /app/) injetam o script_uri como tag com o src exato que o getSopProvider procura (verificado em produção numa loja só); e limites de rate do endpoint accesstoken sob volume real de cotações.

🤖 Revisão gerada com Claude Code

vitorrgg and others added 2 commits October 27, 2026 17:40
…ts token requests

The OAuth and SOP token requests scheduled a retry on 429/5xx but rejected
right away, so the retry never saved the request. With a new SOP token on
every `list_payments`, a rate limit would unlist the credit card on that
quote. `requestWithRetry` waits and tries once more before rejecting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o the store

`bpmpi_merchant_newcustomer` was true for returning buyers and false for
first orders: now true only with no previous orders, left out when the
orders are unknown. `bpmpi_billto_customerid` is sent only as CPF (11
digits) or CNPJ (14).

The card client gets unit tests on the minified asset served by the CDN,
run in `vm` with a minimal DOM: SOP provider by the loaded script, CVV on
the SOP form, 3DS fields in the documented format with empty ones left
out, spent SOP token never sent again, and the 3DS refusal message equal
to the server one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vitorrgg

Copy link
Copy Markdown
Member Author

Ajustes do review em c9324fc e fbbe11d.

R1, testes do card client: tests-unit/card-client.test.mjs roda o asset minificado (o mesmo que o CDN serve) em vm, com um DOM mínimo. Os papéis do SOP e do MPI são simulados.

  • (a) Campos do 3DS:
    • telefone com máscara vira 5511…, e sem DDD é omitido;
    • CEP só com 8 dígitos;
    • CPF/CNPJ só com 11 ou 14 dígitos;
    • nenhum bpmpi_device_1_* e nenhum input vazio;
    • merchant_url pela origem, ou pelo settings.domain quando houver;
    • endereço padrão (default) no lugar do primeiro.
  • (b) provider é cielo só com o script SOP da Cielo na página.
  • (c) Token já gasto é rejeitado sem chamar bpSop_silentOrderPost. onError também gasta o token, e onInvalid não gasta.
  • (d) A recusa do 3DS sai com err.message === REFUSAL_MESSAGE, comparada de forma comportamental e não por texto no asset (o uglify junta as strings).
  • Também: o CVV vai na classe bp-sop-cardcvv.

R2, retry de 429: extraí requestWithRetry (lib-mjs/lib/braspag/request-with-retry.mjs). Ele espera e tenta uma vez antes de rejeitar. Usei no token SOP e também no OAuth (admin/create-auth.mjs, mesmo defeito). tests-unit/request-with-retry.test.mjs cobre:

  • 429 → 200 resolve;
  • 5xx tenta de novo;
  • 429 duas vezes rejeita após um retry;
  • 401 e erro de rede não tentam de novo.

Mantive os 7 s do original. O módulo tem 20 s de limite, então sobra folga para o retry.

O1: bpmpi_merchant_newcustomer agora é true só sem pedidos anteriores, e é omitido quando orders não vem no objeto do cliente.

Nit, customerid: só 11 ou 14 dígitos.

Sem mudança de código, com justificativa:

  • O2, sandbox Cielo: o script sandbox da Cielo que o list_payments carrega é o transactionsandbox.pagador.com.br, e não há loja Cielo com credenciais SOP de sandbox para validar se lá precisa de provider: 'cielo'. Deixei como está e documentado no README (só produção). Prefiro não mudar o sandbox no escuro.
  • O3, skew de versão: procede. Enquanto a loja não atualiza o app, o servidor ainda cacheia o token por 2 min, e o "recarregue" pode trazer o mesmo token dentro dessa janela. Não piora em relação a hoje, que dá 500 mudo. Resolve quando a loja atualiza para a versão com este PR. A Bom Ar atualiza logo após o release.
  • Nits:
    • Os docs braspagAdmin/sop_* órfãos expiram por lógica (ninguém lê). Limpar exigiria script por loja e não vale o risco.
    • onError em falha de rede: mantive o trade-off conservador, como você descreveu.
  • Token single-use fora da Cielo: o 500 no reuso foi reproduzido no endpoint da Braspag (www.pagador.com.br), e o app legado já apagava o cache depois de cada transação ("can only be used once"). Então a premissa vale para a Braspag pura também.

Testes: node --test packages/apps/braspag/tests-unit/ → 31 passando (19 + 12 novos). ESLint sem erros (os 2 warnings quote-props que restam são de create-axios.mjs, não tocado). O asset minificado novo foi testado de novo num navegador no domínio da Bom Ar em produção: SOP na Cielo com CVV (201), formulário 3DS limpo e segunda tentativa barrada sem requisição.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant