Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/apps/braspag/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ 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@<commit>/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), 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)

Credit card authentication with the Cielo 3DS script (MPI V2), configured on
Expand All @@ -39,6 +53,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.
Expand Down
121 changes: 92 additions & 29 deletions packages/apps/braspag/assets/braspag-card-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,15 +107,30 @@
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;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-`
+ `${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,
Expand All @@ -127,25 +144,36 @@
bpmpi_cardexpirationmonth: cardClient.month.toString(),
bpmpi_cardexpirationyear: `20${cardClient.year.toString()}`,
bpmpi_default_card: true,
bpmpi_billto_customerid: customer.doc_number,
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_customerid: docNumber.length === 11 || docNumber.length === 14
? docNumber
: undefined,
// New to the store: no previous orders
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
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,
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,
Expand All @@ -165,10 +193,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);
Expand Down Expand Up @@ -231,20 +261,33 @@
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, with no new `window` variable.
*/
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 = `
<input type="text" class="bp-sop-cardtype" value="creditCard" style="display: none;">
<input type="text" class="bp-sop-cardcvv" value="${cardClient.cvc}" style="display: none;">
<input type="text" class="bp-sop-cardnumber" value="${cardClient.number}" style="display: none;">
<input type="text" class="bp-sop-cardexpirationdate" value="${cardClient.month.toString()}/20${cardClient.year.toString()}" style="display: none;">
<input type="text" class="bp-sop-cardholdername" value="${cardClient.name}" style="display: none;">
`;

// 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
Expand All @@ -253,13 +296,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)));
Expand All @@ -276,7 +333,7 @@
sendHash();
return;
}
const card3ds = { ...cardClient, fingerPrintId };
const card3ds = { ...cardClient };
delete card3ds.cvc;
load3ds(card3ds).then(({ status, data: out3ds }) => {
data.status3ds = status;
Expand All @@ -299,12 +356,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,
Expand Down
10 changes: 0 additions & 10 deletions packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 7 additions & 4 deletions packages/apps/braspag/lib-mjs/braspag-list-payments.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
24 changes: 9 additions & 15 deletions packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import axios from 'axios';
import qs from 'qs';
import requestWithRetry, { TOKEN_TIMEOUT } 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}`,
};

/*
Expand All @@ -15,18 +16,11 @@ 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,
timeout: TOKEN_TIMEOUT,
}));
return data;
};

export default createAuth;
18 changes: 18 additions & 0 deletions packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const TOKEN_TIMEOUT = 4000;

/**
* Runs `request` once more after `delayMs` when Braspag answers 429.
* 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 = 2000) => {
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;
Loading
Loading