From 077f906dd796698d8b8d03f68faec97cd0b8e71f Mon Sep 17 00:00:00 2001 From: Vitor Date: Tue, 27 Oct 2026 16:36:03 -0300 Subject: [PATCH 1/5] fix(braspag): Let Cielo merchants pay by credit card with 3DS in production 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 --- packages/apps/braspag/README.md | 19 +++ .../braspag/assets/braspag-card-client.js | 118 +++++++++++++----- .../lib-mjs/braspag-create-transaction.mjs | 10 -- .../lib-mjs/lib/braspag/3ds/policy.mjs | 6 +- .../lib/braspag/sop/get-access-token.mjs | 57 ++------- 5 files changed, 123 insertions(+), 87 deletions(-) diff --git a/packages/apps/braspag/README.md b/packages/apps/braspag/README.md index ee9ba4bc2..5b25bec39 100644 --- a/packages/apps/braspag/README.md +++ b/packages/apps/braspag/README.md @@ -26,6 +26,19 @@ To test unreleased code on a store, set `BRASPAG_CARD_CLIENT_URI` to the file served from GitHub, e.g. `https://cdn.jsdelivr.net/gh/ecomplus/cloud-commerce@/packages/apps/braspag/assets/braspag-card-client.js`. +## Silent Order Post + +The card goes from the browser straight to the SOP API, which answers a +`PaymentToken` for the transaction. + +- **Cielo merchants:** the SOP script posts to Braspag unless `provider` is + `cielo`, and Braspag answers 500 to a Cielo merchant. With `is_cielo` in + production, `list_payments` loads the Cielo SOP script, and the card client + sets `provider: 'cielo'` when that script is on the page. +- **Single-use token:** each SOP access token tokenizes one card; reused, the + API answers 500 without CORS headers. Every `list_payments` gets a new token + (no cache), and a second attempt on the same page asks the buyer to reload. + ## 3DS (Cielo MPI) Credit card authentication with the Cielo 3DS script (MPI V2), configured on @@ -39,6 +52,12 @@ Credit card authentication with the Cielo 3DS script (MPI V2), configured on | `timeout` | Seconds for the cardholder to finish the challenge (30–900; default 300 when required, 30 otherwise) | | `fraud_analysis` | Keep ClearSale fraud analysis on authenticated transactions (default: authenticated ones are captured without it, as before) | +In production the MPI refuses the whole authentication with `102` "Invalid +field value" when a buyer field is out of format, empty ones included. The card +client sends only valid values: phone as `55` + area code + number, zip code +with 8 digits, CPF/CNPJ digits, and `bpmpi_merchant_url` from the page origin +when `storefront.settings` is empty (v3 checkout). + Accepted ECI (Cielo table): Visa, Elo and Amex `05`/`06`, Mastercard `02`/`01`. The result goes to the transaction `custom_fields` (`3ds`, `3ds_eci`, `3ds_versao`, `3ds_referencia`), shown on the order. diff --git a/packages/apps/braspag/assets/braspag-card-client.js b/packages/apps/braspag/assets/braspag-card-client.js index b13d05ebf..5e10dd530 100644 --- a/packages/apps/braspag/assets/braspag-card-client.js +++ b/packages/apps/braspag/assets/braspag-card-client.js @@ -74,7 +74,9 @@ */ // Keep in sync with `REFUSAL_MESSAGE` on `lib-mjs/lib/braspag/3ds/policy.mjs` const refusal3dsMessage = 'O banco não autenticou esta compra no cartão (3DS). ' - + 'Tente novamente ou pague com Pix ou boleto.'; + + 'Recarregue a página para tentar de novo, ou pague com Pix ou boleto.'; + + const onlyDigits = (value) => (value == null ? '' : String(value).replace(/\D/g, '')); // Keep in sync with `parse3dsResult` on `lib-mjs/lib/braspag/3ds/policy.mjs`: // ECI with liability shift (Visa/Elo/Amex 05/06, Mastercard 02/01), CAVV @@ -105,7 +107,9 @@ const form3ds = document.createElement('form'); form3ds.id = 'braspag3ds'; form3ds.style.display = 'none'; - const shippingAddress = customer.addresses?.[0] || {}; + const address = customer.addresses?.find((addr) => addr.default) + || customer.addresses?.[0] + || {}; const formatDate = (date) => { if (!date) return undefined; const d = typeof date === 'string' ? new Date(date) : date; @@ -113,7 +117,20 @@ + `${String(d.getDate()).padStart(2, '0')}`; }; const ip64 = await getIp64(); - // https://docs.cielo.com.br/gateway/docs/2-mapeando-as-classes + const name = (customer.fullname || cardClient.name || '').slice(0, 120); + const docNumber = onlyDigits(customer.doc_number); + const phone = customer.phones?.[0]; + let phoneNumber = onlyDigits(phone?.number); + if (phoneNumber.length === 10 || phoneNumber.length === 11) { + phoneNumber = `${phone.country_code || 55}${phoneNumber}`; + } + const zip = onlyDigits(address.zip); + /* + * https://docs.cielo.com.br/gateway/docs/2-mapeando-as-classes + * In production the MPI refuses the whole authentication with 102 + * "Invalid field value" when a field is out of format, and an empty + * field counts: values that don't fit are left out (`undefined`) + */ const fields = { bpmpi_auth: true, bpmpi_auth_notifyonly: false, @@ -127,25 +144,34 @@ bpmpi_cardexpirationmonth: cardClient.month.toString(), bpmpi_cardexpirationyear: `20${cardClient.year.toString()}`, bpmpi_default_card: true, - bpmpi_billto_customerid: customer.doc_number, + bpmpi_billto_customerid: docNumber.length >= 11 && docNumber.length <= 14 + ? docNumber + : undefined, bpmpi_merchant_newcustomer: customer.orders?.length > 1, - bpmpi_billto_contactname: customer.fullname || cardClient.name, - bpmpi_billto_name: customer.fullname || cardClient.name, - bpmpi_billto_phonenumber: customer.phones?.[0]?.number, + bpmpi_billto_contactname: name, + bpmpi_billto_name: name, + // Country code, area code and number, e.g. 5511999999999 + bpmpi_billto_phonenumber: /^\d{12,15}$/.test(phoneNumber) ? phoneNumber : undefined, bpmpi_billto_email: customer.main_email, - bpmpi_billto_street1: shippingAddress.street || shippingAddress.line_address, - bpmpi_billto_street2: shippingAddress.number, - bpmpi_billto_city: shippingAddress.city, - bpmpi_billto_state: shippingAddress.province_code, - bpmpi_billto_country: shippingAddress.country_code || 'BR', - bpmpi_billto_zipcode: shippingAddress.zip, + // Street and number, then complement and borough + bpmpi_billto_street1: [address.street || address.line_address, address.number] + .filter(Boolean).join(', ').slice(0, 60), + bpmpi_billto_street2: [address.complement, address.borough] + .filter(Boolean).join(' - ').slice(0, 60), + bpmpi_billto_city: address.city, + bpmpi_billto_state: address.province_code, + bpmpi_billto_country: address.country_code || 'BR', + bpmpi_billto_zipcode: zip.length === 8 ? zip : undefined, bpmpi_shipto_sameasbillto: true, bpmpi_device_ipaddress: ip64, - bpmpi_device_1_fingerprint: cardClient.fingerPrintId, - bpmpi_device_1_provider: 'clearsale', + // No `bpmpi_device_1_*`: the ClearSale session ID is for the antifraud, + // and the 3DS device providers are only cardinal, inauth and threatmetrix bpmpi_device_channel: 'Browser', bpmpi_transaction_mode: 'S', - bpmpi_merchant_url: settings.domain && `https://${settings.domain}`, + // `storefront.settings` is empty on the v3 checkout (/app/) + bpmpi_merchant_url: settings.domain + ? `https://${settings.domain}` + : window.location.origin, bpmpi_order_recurrence: false, bpmpi_order_productcode: 'PHY', bpmpi_order_marketingoptin: customer.accepts_marketing, @@ -165,10 +191,12 @@ fields[`bpmpi_cart_${nItems}_unitprice`] = Math.round(price * 100); }); Object.keys(fields).forEach((className) => { + const value = fields[className]; + if (value === undefined || value === null || value === '') return; const input = document.createElement('input'); input.type = 'hidden'; input.className = className; - input.value = fields[className] == null ? '' : fields[className]; + input.value = value; form3ds.appendChild(input); }); document.body.appendChild(form3ds); @@ -231,20 +259,34 @@ check(); }); + /* + * The SOP script posts the card to Braspag unless `provider` is `cielo`, + * and Braspag answers 500 to Cielo merchants. The server loads the Cielo + * SOP script only for Cielo in production (`is_cielo`), so that script is + * the signal: no new `window` variable, which also fixes stores still on + * the onload expression of previous releases. + */ + const getSopProvider = () => (document.querySelector( + 'script[src^="https://transaction.cieloecommerce.cielo.com.br/"]', + ) ? 'cielo' : 'braspag'); + + // A SOP access token tokenizes a single card: reused, the SOP API answers 500 + // without CORS headers. A new one only comes with a new `list_payments`, + // which the checkout requests on reload + const usedAccessTokens = []; + const reloadMessage = 'Recarregue a página para tentar de novo, ou pague com Pix ou boleto.'; + const hashCard = (cardClient, fingerPrintId) => { const isSandbox = window._braspagIsSandbox; const accessToken = window._braspagAccessToken; + if (usedAccessTokens.indexOf(accessToken) > -1) { + const error = new Error('Braspag SOP access token already used'); + error.userMsg = ` ${reloadMessage}`; + return Promise.reject(error); + } console.log('Session ID captured:', fingerPrintId); injectClearSaleScript(fingerprintApp, fingerPrintId); - const elementsForm = ` - - - - - `; - // The SOP script reads the first matching input it finds: a leftover form // from a previous attempt would tokenize the old card while the 3DS // authenticates the new one @@ -253,13 +295,27 @@ const newForm = document.createElement('form'); newForm.setAttribute('id', 'formBraspag'); - newForm.innerHTML = elementsForm; + const sopFields = { + 'bp-sop-cardtype': 'creditCard', + 'bp-sop-cardnumber': cardClient.number, + 'bp-sop-cardexpirationdate': `${cardClient.month.toString()}/20${cardClient.year.toString()}`, + 'bp-sop-cardholdername': cardClient.name, + 'bp-sop-cardcvv': cardClient.cvc, + }; + Object.keys(sopFields).forEach((className) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.className = className; + input.value = sopFields[className] == null ? '' : sopFields[className]; + newForm.appendChild(input); + }); document.body.appendChild(newForm); return new Promise((resolve, reject) => { const options = { accessToken, onSuccess(response) { + usedAccessTokens.push(accessToken); if (response.PaymentToken) { const data = { token: response.PaymentToken, fingerPrintId }; const sendHash = () => resolve(window.btoa(JSON.stringify(data))); @@ -276,7 +332,7 @@ sendHash(); return; } - const card3ds = { ...cardClient, fingerPrintId }; + const card3ds = { ...cardClient }; delete card3ds.cvc; load3ds(card3ds).then(({ status, data: out3ds }) => { data.status3ds = status; @@ -299,12 +355,18 @@ } }, onError(response) { - reject(response); + // The request reached the API (`onInvalid` doesn't): token spent + usedAccessTokens.push(accessToken); + const error = new Error(`Braspag SOP error ${response?.Code}`); + error.response = response; + error.userMsg = ` ${reloadMessage}`; + reject(error); }, onInvalid(validationResults) { reject(validationResults); }, environment: isSandbox ? 'sandbox' : 'production', + provider: getSopProvider(), language: 'PT', enableBinQuery: false, enableVerifyCard: true, diff --git a/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs b/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs index 20d776dd8..e2eea56cd 100644 --- a/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs +++ b/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs @@ -32,7 +32,6 @@ const createTransaction = async ({ params, application }) => { const transaction = { amount: amount.total }; const timeout = 28000; const db = getFirestore(); - const docSOP = db.doc(`braspagAdmin/sop_${process.env.BRASPAG_CLIENT_ID}`); const orderId = params.order_id; if (appData.merchant_id) { process.env.BRASPAG_MERCHANT_ID = appData.merchant_id; @@ -70,11 +69,6 @@ const createTransaction = async ({ params, application }) => { const status = parseStatus[payment.Status]; if (methodPayment === 'credit_card') { - // delete docSop can only be used once - if (docSOP) { - await docSOP.delete() - .catch(logger.error); - } intermediator.transaction_id = payment.PaymentId; intermediator.transaction_reference = payment.ProofOfSale; intermediator.transaction_code = payment.AcquirerTransactionId; @@ -162,10 +156,6 @@ const createTransaction = async ({ params, application }) => { transaction, }; } catch (error) { - if (docSOP) { - // delete docSop can only be used once - await docSOP.delete().catch(logger.error); - } if (error.name === 'Required3dsError') { logger.info(`3DS required, refused ${orderId}`, { result: error.result }); return { diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs index 7bc94fe84..af34b374b 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs @@ -12,9 +12,11 @@ */ export const AUTHENTICATED_ECI = ['01', '02', '05', '06']; -// Keep in sync with `refusal3dsMessage` on `assets/braspag-card-client.js` +// Keep in sync with `refusal3dsMessage` on `assets/braspag-card-client.js`. +// The card was already sent with this page's SOP token, which is single use: +// trying again needs a reload export const REFUSAL_MESSAGE = 'O banco não autenticou esta compra no cartão (3DS). ' - + 'Tente novamente ou pague com Pix ou boleto.'; + + 'Recarregue a página para tentar de novo, ou pague com Pix ou boleto.'; /** * Status set by the browser script (`status3ds` on the card hash). diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs index e897925ab..7c27a1650 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs @@ -1,5 +1,4 @@ import { logger } from '@cloudcommerce/firebase/lib/config'; -import { getFirestore } from 'firebase-admin/firestore'; import axios from 'axios'; import OAuth2AdminBraspag from '../admin/get-auth.mjs'; @@ -39,53 +38,17 @@ const auth = async (isSandbox) => new Promise((resolve, reject) => { request(); }); +/** + * A SOP access token tokenizes a single card, so every `list_payments` gets a + * new one (the OAuth token behind it stays cached on `admin/get-auth.mjs`). + * Shared through a cache, the token was already used by another buyer or a + * previous attempt, and the SOP API answers 500 without CORS headers. + */ export default function getAccessToken(isSandbox = false) { const { BRASPAG_CLIENT_ID: clientId } = process.env; - const self = this; - const documentRef = getFirestore().doc(`braspagAdmin/sop_${clientId}`); - - this.preparing = new Promise((resolve, reject) => { - const authenticate = (accessToken) => { - self.accessToken = accessToken; - resolve(self); - }; - - const handleAuth = () => { - logger.info(`> Braspag SOP ${clientId} ${isSandbox ? 'SandBox' : ''}`); - auth(isSandbox) - .then((data) => { - logger.info('> Braspag SOP Token Update'); - authenticate(data.accessToken); - const expiresInToString = new Date(data.expiresIn).toISOString(); - if (documentRef) { - documentRef.set({ - accessToken: data.accessToken, - expiresIn: Date.now() + (2 * 60 * 1000), - // ...data, - expiresInToString, - updated_at: new Date().toISOString(), - isSandbox, - }) - .catch(logger.error); - } - }) - .catch(reject); - }; - - if (documentRef) { - documentRef.get() - .then((documentSnapshot) => { - if (documentSnapshot.exists - && Date.now() < documentSnapshot.get('expiresIn') - ) { - authenticate(documentSnapshot.get('accessToken')); - } else { - handleAuth(); - } - }) - .catch(logger.error); - } else { - handleAuth(); - } + logger.info(`> Braspag SOP ${clientId} ${isSandbox ? 'SandBox' : ''}`); + this.preparing = auth(isSandbox).then((data) => { + this.accessToken = data.accessToken; + return this; }); } From c9324fc46543491aed495a5c10d02e0a476a7e4b Mon Sep 17 00:00:00 2001 From: Vitor Date: Tue, 27 Oct 2026 17:40:35 -0300 Subject: [PATCH 2/5] fix(braspag): Keep the credit card on checkout when Braspag rate limits 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 --- .../lib-mjs/lib/braspag/admin/create-auth.mjs | 21 +++----- .../lib/braspag/request-with-retry.mjs | 17 +++++++ .../lib/braspag/sop/get-access-token.mjs | 49 ++++++------------- .../tests-unit/request-with-retry.test.mjs | 48 ++++++++++++++++++ 4 files changed, 85 insertions(+), 50 deletions(-) create mode 100644 packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs create mode 100644 packages/apps/braspag/tests-unit/request-with-retry.test.mjs diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs index 53de934af..992d66c58 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs @@ -1,10 +1,11 @@ import axios from 'axios'; import qs from 'qs'; +import requestWithRetry from '../request-with-retry.mjs'; -const createAuth = (hashLogin, isSandbox) => new Promise((resolve, reject) => { +const createAuth = async (hashLogin, isSandbox) => { const headers = { 'content-type': 'application/x-www-form-urlencoded', - Authorization: `Basic ${hashLogin}`, + 'Authorization': `Basic ${hashLogin}`, }; /* @@ -15,18 +16,8 @@ const createAuth = (hashLogin, isSandbox) => new Promise((resolve, reject) => { const uri = `https://auth${isSandbox ? 'sandbox' : ''}.braspag.com.br/oauth2/token`; const body = qs.stringify({ grant_type: 'client_credentials' }); - - const request = async (isRetry) => { - axios.post(uri, body, { headers }) - .then(({ data }) => resolve(data)) - .catch((err) => { - if (!isRetry && err.response && err.response.status >= 429) { - setTimeout(() => request(true), 7000); - } - reject(err); - }); - }; - request(); -}); + const { data } = await requestWithRetry(() => axios.post(uri, body, { headers })); + return data; +}; export default createAuth; diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs new file mode 100644 index 000000000..d0b3b24e9 --- /dev/null +++ b/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs @@ -0,0 +1,17 @@ +/** + * Runs `request` once more after `delayMs` when Braspag answers 429 or 5xx. + * The token requests had the retry scheduled next to an immediate reject, so + * it never saved the promise: with a SOP token per `list_payments`, a 429 + * would unlist the credit card on that quote. + */ +const requestWithRetry = async (request, delayMs = 7000) => { + try { + return await request(); + } catch (err) { + if (!(err.response?.status >= 429)) throw err; + await new Promise((resolve) => { setTimeout(resolve, delayMs); }); + return request(); + } +}; + +export default requestWithRetry; diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs index 7c27a1650..84588b18c 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs @@ -1,42 +1,21 @@ import { logger } from '@cloudcommerce/firebase/lib/config'; import axios from 'axios'; import OAuth2AdminBraspag from '../admin/get-auth.mjs'; +import requestWithRetry from '../request-with-retry.mjs'; -const auth = async (isSandbox) => new Promise((resolve, reject) => { +const createAccessToken = async (isSandbox) => { const oAuth2AdminBraspag = new OAuth2AdminBraspag(isSandbox); - - const request = async (isRetry) => { - oAuth2AdminBraspag.preparing - .then(() => { - const { accessToken } = oAuth2AdminBraspag; - const urlAuthSOP = `https://transaction${isSandbox ? 'sandbox' : ''}` - + '.pagador.com.br/post/api/public/v2/accesstoken'; - const headers = { - MerchantId: process.env.BRASPAG_MERCHANT_ID, - Authorization: `Bearer ${accessToken}`, - 'content-type': 'application/json', - }; - return axios.post(urlAuthSOP, {}, { headers }); - }) - .then(({ data }) => { - let expire = new Date(); - if (data.ExpiresIn) { - expire = new Date(`${data.ExpiresIn.replace('T', ' ')} UTC-3`); - } - resolve({ - accessToken: data.AccessToken, - expiresIn: expire.getTime(), - }); - }) - .catch((err) => { - if (!isRetry && err.response && err.response.status >= 429) { - setTimeout(() => request(true), 7000); - } - reject(err); - }); + await oAuth2AdminBraspag.preparing; + const urlAuthSOP = `https://transaction${isSandbox ? 'sandbox' : ''}` + + '.pagador.com.br/post/api/public/v2/accesstoken'; + const headers = { + 'MerchantId': process.env.BRASPAG_MERCHANT_ID, + 'Authorization': `Bearer ${oAuth2AdminBraspag.accessToken}`, + 'content-type': 'application/json', }; - request(); -}); + const { data } = await requestWithRetry(() => axios.post(urlAuthSOP, {}, { headers })); + return data.AccessToken; +}; /** * A SOP access token tokenizes a single card, so every `list_payments` gets a @@ -47,8 +26,8 @@ const auth = async (isSandbox) => new Promise((resolve, reject) => { export default function getAccessToken(isSandbox = false) { const { BRASPAG_CLIENT_ID: clientId } = process.env; logger.info(`> Braspag SOP ${clientId} ${isSandbox ? 'SandBox' : ''}`); - this.preparing = auth(isSandbox).then((data) => { - this.accessToken = data.accessToken; + this.preparing = createAccessToken(isSandbox).then((accessToken) => { + this.accessToken = accessToken; return this; }); } diff --git a/packages/apps/braspag/tests-unit/request-with-retry.test.mjs b/packages/apps/braspag/tests-unit/request-with-retry.test.mjs new file mode 100644 index 000000000..3907ac16c --- /dev/null +++ b/packages/apps/braspag/tests-unit/request-with-retry.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert'; +import test, { describe } from 'node:test'; +import requestWithRetry from '../lib-mjs/lib/braspag/request-with-retry.mjs'; + +const httpError = (status) => Object.assign(new Error(`HTTP ${status}`), { + response: { status }, +}); + +// Fake request answering the given results in order +const sequence = (...results) => { + let calls = 0; + const request = async () => { + const result = results[calls]; + calls += 1; + if (result instanceof Error) throw result; + return result; + }; + return { request, calls: () => calls }; +}; + +describe('Braspag token requests', () => { + test('429 then 200: resolves with the retry', async () => { + const { request, calls } = sequence(httpError(429), { data: 'ok' }); + assert.deepStrictEqual(await requestWithRetry(request, 1), { data: 'ok' }); + assert.strictEqual(calls(), 2); + }); + + test('5xx is retried once', async () => { + const { request, calls } = sequence(httpError(503), { data: 'ok' }); + assert.deepStrictEqual(await requestWithRetry(request, 1), { data: 'ok' }); + assert.strictEqual(calls(), 2); + }); + + test('429 twice: rejects after a single retry', async () => { + const { request, calls } = sequence(httpError(429), httpError(429), { data: 'ok' }); + await assert.rejects(requestWithRetry(request, 1), /HTTP 429/); + assert.strictEqual(calls(), 2); + }); + + test('4xx and network errors are not retried', async () => { + const unauthorized = sequence(httpError(401), { data: 'ok' }); + await assert.rejects(requestWithRetry(unauthorized.request, 1), /HTTP 401/); + assert.strictEqual(unauthorized.calls(), 1); + const network = sequence(new Error('ECONNRESET'), { data: 'ok' }); + await assert.rejects(requestWithRetry(network.request, 1), /ECONNRESET/); + assert.strictEqual(network.calls(), 1); + }); +}); From fbbe11d9c004d0e3e66bb8bce5ff3299a378dfc1 Mon Sep 17 00:00:00 2001 From: Vitor Date: Tue, 27 Oct 2026 17:40:37 -0300 Subject: [PATCH 3/5] fix(braspag): Tell the bank correctly whether the card buyer is new to 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 --- .../braspag/assets/braspag-card-client.js | 8 +- .../braspag/tests-unit/card-client.test.mjs | 257 ++++++++++++++++++ 2 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 packages/apps/braspag/tests-unit/card-client.test.mjs diff --git a/packages/apps/braspag/assets/braspag-card-client.js b/packages/apps/braspag/assets/braspag-card-client.js index 5e10dd530..6c0f88ac0 100644 --- a/packages/apps/braspag/assets/braspag-card-client.js +++ b/packages/apps/braspag/assets/braspag-card-client.js @@ -144,10 +144,14 @@ bpmpi_cardexpirationmonth: cardClient.month.toString(), bpmpi_cardexpirationyear: `20${cardClient.year.toString()}`, bpmpi_default_card: true, - bpmpi_billto_customerid: docNumber.length >= 11 && docNumber.length <= 14 + // CPF or CNPJ + bpmpi_billto_customerid: docNumber.length === 11 || docNumber.length === 14 ? docNumber : undefined, - bpmpi_merchant_newcustomer: customer.orders?.length > 1, + // New to the store: no previous orders (left out when unknown) + bpmpi_merchant_newcustomer: Array.isArray(customer.orders) + ? customer.orders.length === 0 + : undefined, bpmpi_billto_contactname: name, bpmpi_billto_name: name, // Country code, area code and number, e.g. 5511999999999 diff --git a/packages/apps/braspag/tests-unit/card-client.test.mjs b/packages/apps/braspag/tests-unit/card-client.test.mjs new file mode 100644 index 000000000..69a41ee59 --- /dev/null +++ b/packages/apps/braspag/tests-unit/card-client.test.mjs @@ -0,0 +1,257 @@ +import assert from 'node:assert'; +import fs from 'node:fs'; +import test, { describe } from 'node:test'; +import vm from 'node:vm'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { minify } from 'uglify-js'; +import { REFUSAL_MESSAGE } from '../lib-mjs/lib/braspag/3ds/policy.mjs'; + +const readFile = (path) => fs.readFileSync(new URL(path, import.meta.url), 'utf8'); +// Same minification as the build: the CDN serves the minified file +const cardClientScript = minify(readFile('../assets/braspag-card-client.js')).code; + +const CIELO_SOP_SCRIPT = 'https://transaction.cieloecommerce.cielo.com.br/post/scripts/silentorderpost-1.0.min.js'; +const BRASPAG_SOP_SCRIPT = 'https://www.pagador.com.br/post/scripts/silentorderpost-1.0.min.js'; + +const card = { + name: 'FULANO DE TAL', number: '4024007197692931', cvc: '123', month: '12', year: '30', brand: 'visa', +}; +const authenticated = { + Cavv: 'AAABBBCCC=', Xid: null, Eci: '05', Version: '2.2.0', ReferenceId: 'ref-1', +}; + +// Just the DOM the card client touches +const createDocument = (onAppend) => { + const createElement = (tagName) => { + const element = { + tagName: tagName.toUpperCase(), + id: '', + className: '', + value: '', + src: '', + style: {}, + children: [], + parentNode: null, + setAttribute(name, value) { + element[name] = value; + }, + appendChild(child) { + child.parentNode = element; + element.children.push(child); + onAppend(child); + return child; + }, + remove() { + if (!element.parentNode) return; + const siblings = element.parentNode.children; + siblings.splice(siblings.indexOf(element), 1); + element.parentNode = null; + }, + }; + return element; + }; + const head = createElement('head'); + const body = createElement('body'); + const allElements = () => { + const list = []; + const walk = (element) => element.children.forEach((child) => { + list.push(child); + walk(child); + }); + walk(head); + walk(body); + return list; + }; + return { + head, + body, + createElement, + getElementById: (id) => allElements().find((element) => element.id === id) || null, + querySelector(selector) { + const [, srcPrefix] = selector.match(/^script\[src\^="(.+)"\]$/) || []; + if (!srcPrefix) throw new Error(`Selector not supported by the test DOM: ${selector}`); + return allElements().find((element) => { + return element.tagName === 'SCRIPT' && element.src.startsWith(srcPrefix); + }) || null; + }, + }; +}; + +const formValues = (form) => Object.fromEntries(form.children.map((input) => { + return [input.className, String(input.value)]; +})); + +/** + * Runs the card client as the checkout does: SOP script on the page, `window` + * variables from the onload expression and `storefrontApp` from the checkout. + * `sopResponse` and `mpiResult` play the Braspag SOP and Cielo MPI scripts. + */ +const loadCardClient = ({ + sopScript = BRASPAG_SOP_SCRIPT, + vars = {}, + customer = {}, + settings = {}, + sopResponse = (options) => options.onSuccess({ PaymentToken: 'payment-token' }), + mpiResult = (config) => config.onSuccess(authenticated), +} = {}) => { + const sopCalls = []; + const window = { + console: { log() {} }, + setTimeout, + btoa, + fetch: async () => ({ ok: true, text: async () => '200.100.50.25' }), + location: { origin: 'https://loja.example.com' }, + storefront: { settings }, + storefrontApp: { amount: { total: 1500 }, customer }, + _braspagAccessToken: 'sop-token-1', + _braspagIsSandbox: false, + ...vars, + bpSop_silentOrderPost(options) { + sopCalls.push(options); + sopResponse(options, sopCalls.length); + }, + }; + window.window = window; + window.document = createDocument((element) => { + if (element.tagName === 'SCRIPT' && element.src.includes('BP.Mpi')) { + setTimeout(() => mpiResult(window.bpmpi_config()), 0); + } + }); + if (sopScript) { + const script = window.document.createElement('script'); + script.src = sopScript; + window.document.head.appendChild(script); + } + vm.runInNewContext(cardClientScript, window); + // ClearSale fills it on a real page + window.document.getElementById('mySessionId').value = 'session-id'; + const hashCard = () => window._braspagCardClient({ ...card }); + return { window, sopCalls, hashCard }; +}; + +const required3ds = { + _braspag3dsToken: '3ds-token', + _braspag3dsRequired: true, + _braspag3dsTimeout: 500, +}; + +describe('Card client', () => { + test('card goes to Cielo only when the Cielo SOP script is on the page', async () => { + const cielo = loadCardClient({ sopScript: CIELO_SOP_SCRIPT }); + await cielo.hashCard(); + assert.strictEqual(cielo.sopCalls[0].provider, 'cielo'); + + const braspag = loadCardClient({ sopScript: BRASPAG_SOP_SCRIPT }); + await braspag.hashCard(); + assert.strictEqual(braspag.sopCalls[0].provider, 'braspag'); + }); + + test('SOP form has the CVV on the class the SOP script reads', async () => { + const { window, hashCard } = loadCardClient(); + await hashCard(); + const sopForm = formValues(window.document.getElementById('formBraspag')); + assert.strictEqual(sopForm['bp-sop-cardcvv'], '123'); + assert.strictEqual(sopForm['bp-sop-cardexpirationdate'], '12/2030'); + assert.strictEqual(sopForm['bp-sop-cardnumber'], card.number); + }); + + test('3DS fields in the documented format, empty ones left out', async () => { + const { window, hashCard } = loadCardClient({ + vars: required3ds, + customer: { + fullname: 'Fulano de Tal', + main_email: 'f@example.com', + doc_number: '123.456.789-09', + phones: [{ country_code: 55, number: '(11) 98765-4321' }], + addresses: [ + { street: 'Rua B', number: 1, zip: '22222-222' }, + { + default: true, + street: 'Rua A', + number: 10, + borough: 'Centro', + city: '', + province_code: 'SP', + country_code: 'BR', + zip: '01310-100', + }, + ], + orders: [{ _id: 'order-1' }], + }, + }); + const hash = JSON.parse(Buffer.from(await hashCard(), 'base64').toString()); + assert.strictEqual(hash.token, 'payment-token'); + assert.strictEqual(hash.status3ds, 'authenticated'); + + const fields = formValues(window.document.getElementById('braspag3ds')); + assert.strictEqual(fields.bpmpi_billto_phonenumber, '5511987654321'); + assert.strictEqual(fields.bpmpi_billto_zipcode, '01310100'); + assert.strictEqual(fields.bpmpi_billto_customerid, '12345678909'); + assert.strictEqual(fields.bpmpi_billto_street1, 'Rua A, 10'); + assert.strictEqual(fields.bpmpi_billto_street2, 'Centro'); + assert.strictEqual(fields.bpmpi_merchant_url, 'https://loja.example.com'); + assert.strictEqual(fields.bpmpi_merchant_newcustomer, 'false'); + assert.strictEqual(fields.bpmpi_device_ipaddress, '200.100.50.25'); + assert.ok(!('bpmpi_billto_city' in fields), 'empty city left out'); + assert.ok(!Object.keys(fields).some((key) => key.startsWith('bpmpi_device_1_'))); + assert.ok(!Object.values(fields).includes(''), 'no empty field'); + }); + + test('3DS fields out of format are left out, not sent', async () => { + const { window, hashCard } = loadCardClient({ + vars: required3ds, + settings: { domain: 'www.loja.com.br' }, + customer: { + fullname: 'Fulano de Tal', + doc_number: '123456789012', + phones: [{ number: '98765-4321' }], + addresses: [{ street: 'Rua A', zip: '1310-100' }], + orders: [], + }, + }); + await hashCard(); + const fields = formValues(window.document.getElementById('braspag3ds')); + assert.ok(!('bpmpi_billto_phonenumber' in fields), 'phone without area code'); + assert.ok(!('bpmpi_billto_customerid' in fields), 'neither CPF nor CNPJ'); + assert.ok(!('bpmpi_billto_zipcode' in fields), 'zip code without 8 digits'); + assert.strictEqual(fields.bpmpi_merchant_url, 'https://www.loja.com.br'); + assert.strictEqual(fields.bpmpi_merchant_newcustomer, 'true'); + }); + + test('a spent SOP token is never sent again', async () => { + const { sopCalls, hashCard } = loadCardClient(); + await hashCard(); + await assert.rejects(hashCard(), (err) => /Recarregue a página/.test(err.userMsg)); + assert.strictEqual(sopCalls.length, 1); + }); + + test('a SOP API error spends the token too', async () => { + const { sopCalls, hashCard } = loadCardClient({ + sopResponse: (options) => options.onError({ Code: 0, Text: '' }), + }); + await assert.rejects(hashCard(), (err) => /Recarregue a página/.test(err.userMsg)); + await assert.rejects(hashCard(), /already used/); + assert.strictEqual(sopCalls.length, 1); + }); + + test('card refused by the SOP script before sending keeps the token', async () => { + const { sopCalls, hashCard } = loadCardClient({ + sopResponse: (options, call) => (call === 1 + ? options.onInvalid([{ Field: 'bp-sop-cardnumber' }]) + : options.onSuccess({ PaymentToken: 'payment-token' })), + }); + await assert.rejects(hashCard()); + assert.ok(await hashCard()); + assert.strictEqual(sopCalls.length, 2); + }); + + test('required 3DS not authenticated: same refusal message as the server', async () => { + const { hashCard } = loadCardClient({ + vars: required3ds, + mpiResult: (config) => config.onFailure({ Eci: '07' }), + }); + await assert.rejects(hashCard(), (err) => { + return err.message === REFUSAL_MESSAGE && err.userMsg === ` ${REFUSAL_MESSAGE}`; + }); + }); +}); From 8f09f9abde972ef642c8b6cf5f3f54389fd3020b Mon Sep 17 00:00:00 2001 From: Leonardo Matos Date: Fri, 30 Oct 2026 18:58:56 -0300 Subject: [PATCH 4/5] fix(braspag): Keep Pix and boleto on checkout when Braspag token requests hang The SOP token is now requested on every `list_payments`, and Pix and boleto wait for it. The SOP and OAuth token requests get a 4s timeout, like the 3DS token, and only a 429 is retried, after 2s. A slow or failing Braspag no longer holds the whole payment list until the module times out. The SOP log line goes to debug, since it now runs on every quote. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011YfvCxVikTh42WGmNu6chf --- .../braspag/lib-mjs/lib/braspag/admin/create-auth.mjs | 7 +++++-- .../lib-mjs/lib/braspag/request-with-retry.mjs | 10 +++++++--- .../lib-mjs/lib/braspag/sop/get-access-token.mjs | 9 ++++++--- .../braspag/tests-unit/request-with-retry.test.mjs | 11 ++++------- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs index 992d66c58..fb6607f9c 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs @@ -1,6 +1,6 @@ import axios from 'axios'; import qs from 'qs'; -import requestWithRetry from '../request-with-retry.mjs'; +import requestWithRetry, { TOKEN_TIMEOUT } from '../request-with-retry.mjs'; const createAuth = async (hashLogin, isSandbox) => { const headers = { @@ -16,7 +16,10 @@ const createAuth = async (hashLogin, isSandbox) => { const uri = `https://auth${isSandbox ? 'sandbox' : ''}.braspag.com.br/oauth2/token`; const body = qs.stringify({ grant_type: 'client_credentials' }); - const { data } = await requestWithRetry(() => axios.post(uri, body, { headers })); + const { data } = await requestWithRetry(() => axios.post(uri, body, { + headers, + timeout: TOKEN_TIMEOUT, + })); return data; }; diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs index d0b3b24e9..9eadf2157 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs @@ -1,14 +1,18 @@ /** - * Runs `request` once more after `delayMs` when Braspag answers 429 or 5xx. + * Runs `request` once more after `delayMs` when Braspag answers 429. * The token requests had the retry scheduled next to an immediate reject, so * it never saved the promise: with a SOP token per `list_payments`, a 429 * would unlist the credit card on that quote. + * Other errors are not retried: `list_payments` awaits these tokens, and Pix + * and boleto would wait along with the card while Braspag is down. */ -const requestWithRetry = async (request, delayMs = 7000) => { +export const TOKEN_TIMEOUT = 4000; + +const requestWithRetry = async (request, delayMs = 2000) => { try { return await request(); } catch (err) { - if (!(err.response?.status >= 429)) throw err; + if (err.response?.status !== 429) throw err; await new Promise((resolve) => { setTimeout(resolve, delayMs); }); return request(); } diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs index 84588b18c..e1c43fd4a 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs @@ -1,7 +1,7 @@ import { logger } from '@cloudcommerce/firebase/lib/config'; import axios from 'axios'; import OAuth2AdminBraspag from '../admin/get-auth.mjs'; -import requestWithRetry from '../request-with-retry.mjs'; +import requestWithRetry, { TOKEN_TIMEOUT } from '../request-with-retry.mjs'; const createAccessToken = async (isSandbox) => { const oAuth2AdminBraspag = new OAuth2AdminBraspag(isSandbox); @@ -13,7 +13,10 @@ const createAccessToken = async (isSandbox) => { 'Authorization': `Bearer ${oAuth2AdminBraspag.accessToken}`, 'content-type': 'application/json', }; - const { data } = await requestWithRetry(() => axios.post(urlAuthSOP, {}, { headers })); + const { data } = await requestWithRetry(() => axios.post(urlAuthSOP, {}, { + headers, + timeout: TOKEN_TIMEOUT, + })); return data.AccessToken; }; @@ -25,7 +28,7 @@ const createAccessToken = async (isSandbox) => { */ export default function getAccessToken(isSandbox = false) { const { BRASPAG_CLIENT_ID: clientId } = process.env; - logger.info(`> Braspag SOP ${clientId} ${isSandbox ? 'SandBox' : ''}`); + logger.debug(`> Braspag SOP ${clientId} ${isSandbox ? 'SandBox' : ''}`); this.preparing = createAccessToken(isSandbox).then((accessToken) => { this.accessToken = accessToken; return this; diff --git a/packages/apps/braspag/tests-unit/request-with-retry.test.mjs b/packages/apps/braspag/tests-unit/request-with-retry.test.mjs index 3907ac16c..8ab9437b5 100644 --- a/packages/apps/braspag/tests-unit/request-with-retry.test.mjs +++ b/packages/apps/braspag/tests-unit/request-with-retry.test.mjs @@ -25,19 +25,16 @@ describe('Braspag token requests', () => { assert.strictEqual(calls(), 2); }); - test('5xx is retried once', async () => { - const { request, calls } = sequence(httpError(503), { data: 'ok' }); - assert.deepStrictEqual(await requestWithRetry(request, 1), { data: 'ok' }); - assert.strictEqual(calls(), 2); - }); - test('429 twice: rejects after a single retry', async () => { const { request, calls } = sequence(httpError(429), httpError(429), { data: 'ok' }); await assert.rejects(requestWithRetry(request, 1), /HTTP 429/); assert.strictEqual(calls(), 2); }); - test('4xx and network errors are not retried', async () => { + test('5xx, other 4xx and network errors are not retried', async () => { + const unavailable = sequence(httpError(503), { data: 'ok' }); + await assert.rejects(requestWithRetry(unavailable.request, 1), /HTTP 503/); + assert.strictEqual(unavailable.calls(), 1); const unauthorized = sequence(httpError(401), { data: 'ok' }); await assert.rejects(requestWithRetry(unauthorized.request, 1), /HTTP 401/); assert.strictEqual(unauthorized.calls(), 1); From e93a6d6b25ccbcb421adb5f378f6a51cb95593cf Mon Sep 17 00:00:00 2001 From: Leonardo Matos Date: Fri, 30 Oct 2026 19:25:36 -0300 Subject: [PATCH 5/5] fix(braspag): Keep the card discount on orders when Braspag tokens fail at checkout The checkout confirmation calls `list_payments` only to match the chosen gateway. It no longer requests a SOP token that the browser never uses, and the credit card stays listed. A token failure at that point no longer drops the card and its discount from the order. Also removes scaffolding comments and redundant test assertions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011YfvCxVikTh42WGmNu6chf --- packages/apps/braspag/README.md | 3 ++- packages/apps/braspag/assets/braspag-card-client.js | 7 ++----- .../apps/braspag/lib-mjs/braspag-list-payments.mjs | 11 +++++++---- .../lib-mjs/lib/braspag/request-with-retry.mjs | 7 ++----- .../lib-mjs/lib/braspag/sop/get-access-token.mjs | 2 -- packages/apps/braspag/tests-unit/card-client.test.mjs | 1 - .../braspag/tests-unit/request-with-retry.test.mjs | 5 +---- 7 files changed, 14 insertions(+), 22 deletions(-) diff --git a/packages/apps/braspag/README.md b/packages/apps/braspag/README.md index 5b25bec39..da03b064a 100644 --- a/packages/apps/braspag/README.md +++ b/packages/apps/braspag/README.md @@ -37,7 +37,8 @@ The card goes from the browser straight to the SOP API, which answers a sets `provider: 'cielo'` when that script is on the page. - **Single-use token:** each SOP access token tokenizes one card; reused, the API answers 500 without CORS headers. Every `list_payments` gets a new token - (no cache), and a second attempt on the same page asks the buyer to reload. + (no cache), except on the checkout confirmation, where the card was already + tokenized. A second attempt on the same page asks the buyer to reload. ## 3DS (Cielo MPI) diff --git a/packages/apps/braspag/assets/braspag-card-client.js b/packages/apps/braspag/assets/braspag-card-client.js index 6c0f88ac0..87342f6de 100644 --- a/packages/apps/braspag/assets/braspag-card-client.js +++ b/packages/apps/braspag/assets/braspag-card-client.js @@ -144,11 +144,10 @@ bpmpi_cardexpirationmonth: cardClient.month.toString(), bpmpi_cardexpirationyear: `20${cardClient.year.toString()}`, bpmpi_default_card: true, - // CPF or CNPJ bpmpi_billto_customerid: docNumber.length === 11 || docNumber.length === 14 ? docNumber : undefined, - // New to the store: no previous orders (left out when unknown) + // New to the store: no previous orders bpmpi_merchant_newcustomer: Array.isArray(customer.orders) ? customer.orders.length === 0 : undefined, @@ -157,7 +156,6 @@ // Country code, area code and number, e.g. 5511999999999 bpmpi_billto_phonenumber: /^\d{12,15}$/.test(phoneNumber) ? phoneNumber : undefined, bpmpi_billto_email: customer.main_email, - // Street and number, then complement and borough bpmpi_billto_street1: [address.street || address.line_address, address.number] .filter(Boolean).join(', ').slice(0, 60), bpmpi_billto_street2: [address.complement, address.borough] @@ -267,8 +265,7 @@ * The SOP script posts the card to Braspag unless `provider` is `cielo`, * and Braspag answers 500 to Cielo merchants. The server loads the Cielo * SOP script only for Cielo in production (`is_cielo`), so that script is - * the signal: no new `window` variable, which also fixes stores still on - * the onload expression of previous releases. + * the signal, with no new `window` variable. */ const getSopProvider = () => (document.querySelector( 'script[src^="https://transaction.cieloecommerce.cielo.com.br/"]', diff --git a/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs b/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs index a05b29cb3..b90475e94 100644 --- a/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs +++ b/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs @@ -46,8 +46,11 @@ const listPayments = async ({ params, application }) => { // below is not awaited): a slow MPI must not sum with the SOP time, Pix and // billet wait for this response on every requote const options3ds = get3dsOptions(appData); + // The checkout confirmation only matches the listed gateway (discount, + // label): the card was already tokenized with the token from the quote + const isConfirmation = Boolean(params.is_checkout_confirmation); let sopTokenPromise = null; - if (process.env.BRASPAG_CLIENT_ID) { + if (process.env.BRASPAG_CLIENT_ID && !isConfirmation) { const getTokenSOPBraspag = new TokenSOPBraspag(isSandbox); sopTokenPromise = getTokenSOPBraspag.preparing .then(() => getTokenSOPBraspag.accessToken); @@ -71,12 +74,12 @@ const listPayments = async ({ params, application }) => { let accessTokenSOP; if (sopResult.status === 'fulfilled') { accessTokenSOP = sopResult.value; - if (accessTokenSOP) { - listPaymentMethod.push('credit_card'); - } } else { logger.error(sopResult.reason); } + if (accessTokenSOP || (isConfirmation && process.env.BRASPAG_CLIENT_ID)) { + listPaymentMethod.push('credit_card'); + } let token3ds; if (token3dsResult.status === 'fulfilled') { token3ds = token3dsResult.value || undefined; diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs index 9eadf2157..fd6b4714b 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs @@ -1,13 +1,10 @@ +export const TOKEN_TIMEOUT = 4000; + /** * Runs `request` once more after `delayMs` when Braspag answers 429. - * The token requests had the retry scheduled next to an immediate reject, so - * it never saved the promise: with a SOP token per `list_payments`, a 429 - * would unlist the credit card on that quote. * Other errors are not retried: `list_payments` awaits these tokens, and Pix * and boleto would wait along with the card while Braspag is down. */ -export const TOKEN_TIMEOUT = 4000; - const requestWithRetry = async (request, delayMs = 2000) => { try { return await request(); diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs index e1c43fd4a..f70ccee2d 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs @@ -23,8 +23,6 @@ const createAccessToken = async (isSandbox) => { /** * A SOP access token tokenizes a single card, so every `list_payments` gets a * new one (the OAuth token behind it stays cached on `admin/get-auth.mjs`). - * Shared through a cache, the token was already used by another buyer or a - * previous attempt, and the SOP API answers 500 without CORS headers. */ export default function getAccessToken(isSandbox = false) { const { BRASPAG_CLIENT_ID: clientId } = process.env; diff --git a/packages/apps/braspag/tests-unit/card-client.test.mjs b/packages/apps/braspag/tests-unit/card-client.test.mjs index 69a41ee59..c85f15a14 100644 --- a/packages/apps/braspag/tests-unit/card-client.test.mjs +++ b/packages/apps/braspag/tests-unit/card-client.test.mjs @@ -191,7 +191,6 @@ describe('Card client', () => { assert.strictEqual(fields.bpmpi_billto_street2, 'Centro'); assert.strictEqual(fields.bpmpi_merchant_url, 'https://loja.example.com'); assert.strictEqual(fields.bpmpi_merchant_newcustomer, 'false'); - assert.strictEqual(fields.bpmpi_device_ipaddress, '200.100.50.25'); assert.ok(!('bpmpi_billto_city' in fields), 'empty city left out'); assert.ok(!Object.keys(fields).some((key) => key.startsWith('bpmpi_device_1_'))); assert.ok(!Object.values(fields).includes(''), 'no empty field'); diff --git a/packages/apps/braspag/tests-unit/request-with-retry.test.mjs b/packages/apps/braspag/tests-unit/request-with-retry.test.mjs index 8ab9437b5..f7c2e6735 100644 --- a/packages/apps/braspag/tests-unit/request-with-retry.test.mjs +++ b/packages/apps/braspag/tests-unit/request-with-retry.test.mjs @@ -31,13 +31,10 @@ describe('Braspag token requests', () => { assert.strictEqual(calls(), 2); }); - test('5xx, other 4xx and network errors are not retried', async () => { + test('5xx and network errors are not retried', async () => { const unavailable = sequence(httpError(503), { data: 'ok' }); await assert.rejects(requestWithRetry(unavailable.request, 1), /HTTP 503/); assert.strictEqual(unavailable.calls(), 1); - const unauthorized = sequence(httpError(401), { data: 'ok' }); - await assert.rejects(requestWithRetry(unauthorized.request, 1), /HTTP 401/); - assert.strictEqual(unauthorized.calls(), 1); const network = sequence(new Error('ECONNRESET'), { data: 'ok' }); await assert.rejects(requestWithRetry(network.request, 1), /ECONNRESET/); assert.strictEqual(network.calls(), 1);