Skip to content
Open
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
45 changes: 45 additions & 0 deletions packages/apps/braspag/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,46 @@
# `@cloudcommerce/app-braspag`

## 3DS (Cielo MPI)

Credit card authentication with the Cielo 3DS script (MPI V2), configured on
`braspag_3ds` (app hidden data):

| Field | Effect |
|---|---|
| `client_id`, `client_secret` | 3DS credentials (Cielo e-commerce portal) |
| `establishment_code`, `merchant_name`, `mcc` | Merchant data sent to the MPI token |
| `required` | **No card without 3DS.** Any result other than authenticated is refused before calling Cielo (failed challenge, card not enrolled, brand without 3DS, script error or timeout). If the 3DS token can't be generated, credit card is not listed and Pix/billet keep working |
| `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) |

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.

The 3DS rules live in `lib-mjs/lib/braspag/3ds/policy.mjs`, apart from the
authentication script: MPI V3 keeps the same authorization data (Cavv, Xid,
Eci, Version, ReferenceId), so only the browser/token step changes.

Admin settings schema for the Market app, inside `braspag_3ds.schema.properties`:

```json
"required": {
"type": "boolean",
"default": false,
"title": "3DS obrigatório",
"description": "Recusar compra no cartão não autenticada e ocultar o cartão quando o 3DS estiver indisponível"
},
"timeout": {
"type": "integer",
"minimum": 30,
"maximum": 900,
"title": "Tempo para o desafio (segundos)"
},
"fraud_analysis": {
"type": "boolean",
"default": false,
"title": "Antifraude também nas compras autenticadas"
}
```

Unit tests: `node --test tests-unit/`.
173 changes: 171 additions & 2 deletions packages/apps/braspag/assets/braspag-onload-expression.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,148 @@

injectClearSaleScript(fingerprintApp);

/*
* 3DS authentication with the Cielo MPI script (V2), ported from the legacy
* app (hosting/card-client.js). Resolves `{ status, data }` and never
* rejects: the required 3DS policy is decided on `_braspagHashCard`.
*/
const refusal3dsMessage = 'O banco não autenticou esta compra no cartão (3DS). '
+ 'Tente novamente ou pague com Pix ou boleto.';
const load3ds = (cardClient) => new Promise((resolve) => {
let isDone = false;
const done = (status, data) => {
if (isDone) return;
isDone = true;
console.log('3ds', status, data);
resolve({ status, data });
};
setTimeout(() => done('timeout'), window._braspag3dsTimeout || 30000);
const settings = window.storefront?.settings || {};
const { amount, customer = {}, items } = window.storefrontApp || {};

const setup3dsForm = async () => {
const previousForm = document.getElementById('braspag3ds');
if (previousForm) previousForm.remove();
const form3ds = document.createElement('form');
form3ds.id = 'braspag3ds';
form3ds.style.display = 'none';
const shippingAddress = 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')}`;
};
let ip64;
try {
const ipResponse = await fetch('https://api64.ipify.org/');
if (ipResponse.ok) ip64 = await ipResponse.text();
} catch {
//
}
// https://docs.cielo.com.br/gateway/docs/2-mapeando-as-classes
const fields = {
bpmpi_auth: true,
bpmpi_auth_notifyonly: false,
bpmpi_accesstoken: window._braspag3dsToken,
bpmpi_ordernumber: `R${Math.round(Math.random() * (999999 - 199999) + 199999)}`,
bpmpi_currency: 'BRL',
bpmpi_totalamount: Math.round((amount?.total || 0) * 100),
bpmpi_installments: 1,
bpmpi_paymentmethod: 'credit',
bpmpi_cardnumber: cardClient.number,
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_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_shipto_sameasbillto: true,
bpmpi_device_ipaddress: ip64,
bpmpi_device_1_fingerprint: cardClient.fingerPrintId,
bpmpi_device_1_provider: 'clearsale',
bpmpi_device_channel: 'Browser',
bpmpi_transaction_mode: 'S',
bpmpi_merchant_url: settings.domain && `https://${settings.domain}`,
bpmpi_order_recurrence: false,
bpmpi_order_productcode: 'PHY',
bpmpi_order_marketingoptin: customer.accepts_marketing,
bpmpi_useraccount_guest: false,
bpmpi_useraccount_createddate: formatDate(customer.created_at),
bpmpi_useraccount_changeddate: formatDate(customer.updated_at),
};
let nItems = 0;
items?.forEach((item) => {
const price = item.final_price || item.price;
if (!item.quantity || !item.sku || !price) return;
nItems += 1;
fields[`bpmpi_cart_${nItems}_description`] = item.name || item.sku;
fields[`bpmpi_cart_${nItems}_name`] = item.name || item.sku;
fields[`bpmpi_cart_${nItems}_sku`] = item.sku;
fields[`bpmpi_cart_${nItems}_quantity`] = item.quantity;
fields[`bpmpi_cart_${nItems}_unitprice`] = Math.round(price * 100);
});
Object.keys(fields).forEach((className) => {
const input = document.createElement('input');
input.type = 'hidden';
input.className = className;
input.value = fields[className] == null ? '' : fields[className];
form3ds.appendChild(input);
});
document.body.appendChild(form3ds);
};

const isSandbox3ds = Boolean(window._braspag3dsIsSandbox);
window.bpmpi_config = () => ({
onReady() {
window.bpmpi_authenticate();
},
// Card eligible and cardholder authenticated
onSuccess(data) {
done('authenticated', data);
},
// Card eligible, but the cardholder failed the challenge
onFailure(data) {
done('failure', data);
},
// Card not eligible for authentication
onUnenrolled(data) {
done('unenrolled', data);
},
// `bpmpi_auth` false
onDisabled() {
done('disabled');
},
onError(data) {
done('error', data);
},
onUnsupportedBrand(data) {
done('unsupported_brand', data);
},
Environment: isSandbox3ds ? 'SDB' : 'PRD',
Debug: isSandbox3ds,
});

setup3dsForm().then(() => {
const script = document.createElement('script');
script.src = isSandbox3ds
? 'https://mpisandbox.braspag.com.br/Scripts/BP.Mpi.3ds20.min.js'
: 'https://mpi.braspag.com.br/Scripts/BP.Mpi.3ds20.min.js';
script.async = true;
script.onerror = () => done('script_error');
document.body.appendChild(script);
}).catch(() => done('script_error'));
});

window._braspagHashCard = function hashCard(cardClient) {
const fingerPrintId = document.getElementById('mySessionId').value;
if (fingerPrintId && fingerPrintId !== '') {
Expand Down Expand Up @@ -58,8 +200,35 @@
accessToken,
onSuccess(response) {
if (response.PaymentToken) {
const data = JSON.stringify({ token: response.PaymentToken, fingerPrintId });
resolve(window.btoa(data));
const data = { token: response.PaymentToken, fingerPrintId };
const sendHash = () => resolve(window.btoa(JSON.stringify(data)));
const is3dsRequired = Boolean(window._braspag3dsRequired);
const refuse = () => {
const error = new Error(refusal3dsMessage);
// CreditCardForm appends `userMsg` to the "invalid card" toast
error.userMsg = ` ${refusal3dsMessage}`;
reject(error);
};
if (!window._braspag3dsToken) {
if (is3dsRequired) {
refuse();
return;
}
sendHash();
return;
}
const card3ds = { ...cardClient, fingerPrintId };
delete card3ds.cvc;
load3ds(card3ds).then(({ status, data: out3ds }) => {
data.status3ds = status;
if (status === 'authenticated' && out3ds && typeof out3ds === 'object') {
data.out3ds = out3ds;
} else if (is3dsRequired) {
refuse();
return;
}
sendHash();
});
} else {
const error = new Error('Payment Token not found. Please try again or refresh the page.');
reject(error);
Expand Down
25 changes: 25 additions & 0 deletions packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { getFirestore } from 'firebase-admin/firestore';
import createAxios from './lib/braspag/create-axios.mjs';
import { parseStatus } from './lib/braspag/parse-utils.mjs';
import bodyToBraspag from './lib/braspag/payload-to-transaction.mjs';
import {
get3dsOptions,
parse3dsResult,
to3dsCustomFields,
} from './lib/braspag/3ds/policy.mjs';
import addInstallments from './lib/payments/add-installments.mjs';

const createTransaction = async ({ params, application }) => {
Expand Down Expand Up @@ -85,6 +90,18 @@ const createTransaction = async ({ params, application }) => {
};
}

// 3DS result on the order, so the merchant doesn't check it by hand
try {
const hashCard = JSON.parse(Buffer.from(params.credit_card.hash, 'base64'));
if (get3dsOptions(appData).hasCredentials || hashCard.status3ds) {
transaction.custom_fields = to3dsCustomFields(
parse3dsResult(hashCard.out3ds, hashCard.status3ds),
);
}
} catch (err) {
logger.warn('Cannot parse 3DS result from card hash', { err });
}

if (appData.installments) {
const installmentsNumber = params.installments_number || 1;
// list all installment options
Expand Down Expand Up @@ -160,6 +177,14 @@ const createTransaction = async ({ params, application }) => {
// 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 {
status: 409,
error: 'BRASPAG_3DS_REQUIRED',
message: error.message,
};
}
// try to debug request error
const errCode = 'BRASPAG_TRANSACTION_ERR';
let { message } = error;
Expand Down
40 changes: 39 additions & 1 deletion packages/apps/braspag/lib-mjs/braspag-list-payments.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { join as joinPath } from 'node:path';
import url from 'node:url';
import { logger } from '@cloudcommerce/firebase/lib/config';
import TokenSOPBraspag from './lib/braspag/sop/get-access-token.mjs';
import get3dsToken from './lib/braspag/3ds/get-3ds-token.mjs';
import { get3dsOptions } from './lib/braspag/3ds/policy.mjs';
import addInstallments from './lib/payments/add-installments.mjs';

const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
Expand Down Expand Up @@ -58,6 +60,34 @@ const listPayments = async ({ params, application }) => {
}
}

// 3DS: token fetched before listing, the forEach below is not awaited
const options3ds = get3dsOptions(appData);
let token3ds;
if (accessTokenSOP && options3ds.hasCredentials) {
const config3ds = appData.braspag_3ds;
try {
token3ds = await get3dsToken({
clientId: config3ds.client_id,
clientSecret: config3ds.client_secret,
establishmentCode: config3ds.establishment_code,
merchantName: config3ds.merchant_name,
mcc: config3ds.mcc,
isSandbox,
});
} catch (error) {
logger.warn('Cannot get 3DS token', { error });
}
}
if (options3ds.isRequired && !token3ds?.accessToken) {
// Required 3DS: with no authentication available, no credit card at all
// (Pix and banking billet keep working)
const i = listPaymentMethod.indexOf('credit_card');
if (i > -1) {
logger.warn('Credit card unlisted: 3DS required but unavailable');
listPaymentMethod.splice(i, 1);
}
}

const response = {
payment_gateways: [],
};
Expand Down Expand Up @@ -168,9 +198,17 @@ const listPayments = async ({ params, application }) => {
: 'https://transaction.cieloecommerce.cielo.com.br';
}

let onload3ds = '';
if (token3ds?.accessToken) {
onload3ds = `window._braspag3dsToken="${token3ds.accessToken}";`
+ `window._braspag3dsIsSandbox=${token3ds.isSandbox};`
+ `window._braspag3dsRequired=${options3ds.isRequired};`
+ `window._braspag3dsTimeout=${options3ds.timeoutMs};`;
}
gateway.js_client = {
script_uri: `${baseScriptUri}/post/scripts/silentorderpost-1.0.min.js`,
onload_expression: `window._braspagAccessToken="${accessTokenSOP}";`
onload_expression: onload3ds
+ `window._braspagAccessToken="${accessTokenSOP}";`
+ `window._braspagIsSandbox=${isSandbox};`
+ `window._braspagFingerprintApp="${fingerprintApp}";`
+ fs.readFileSync(joinPath(__dirname, '../assets/braspag-onload-expression.min.js'), 'utf8'),
Expand Down
54 changes: 54 additions & 0 deletions packages/apps/braspag/lib-mjs/lib/braspag/3ds/get-3ds-token.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { getFirestore } from 'firebase-admin/firestore';
import axios from 'axios';

// Public sandbox client of the Cielo/Braspag MPI (same check as the legacy app)
const SANDBOX_CLIENT_ID = 'dba3a8db-fa54-40e0-8bab-7bfb9b6f2e2e';

/**
* Access token for the 3DS script (MPI V2), cached on Firestore until it
* expires. Ported from the legacy app `functions/lib/braspag/3ds/get-3ds-token.js`.
*/
const get3dsToken = async ({
clientId,
clientSecret,
establishmentCode,
merchantName,
mcc,
isSandbox: _isSandbox,
}) => {
const isSandbox = Boolean(_isSandbox) || clientId === SANDBOX_CLIENT_ID;
const documentRef = getFirestore().doc(`braspagAdmin/3ds_${clientId}`);
const documentSnapshot = await documentRef.get();
if (
documentSnapshot.exists
&& documentSnapshot.get('isSandbox') === isSandbox
&& Date.now() < documentSnapshot.get('expiresAt')
) {
return { accessToken: documentSnapshot.get('accessToken'), isSandbox };
}
const url = isSandbox
? 'https://mpisandbox.braspag.com.br/v2/auth/token'
: 'https://mpi.braspag.com.br/v2/auth/token';
const { data } = await axios.post(url, {
EstablishmentCode: establishmentCode,
MerchantName: merchantName,
MCC: mcc,
}, {
auth: { username: clientId, password: clientSecret },
timeout: 7000,
});
if (!data?.access_token) {
const err = new Error('Cannot generate 3DS token');
err.data = data;
throw err;
}
await documentRef.set({
accessToken: data.access_token,
// Renew a minute earlier to not hand out a token about to expire
expiresAt: Date.now() + Math.max((Number(data.expires_in) || 120) - 60, 30) * 1000,
isSandbox,
});
return { accessToken: data.access_token, isSandbox };
};

export default get3dsToken;
Loading
Loading