diff --git a/packages/apps/braspag/README.md b/packages/apps/braspag/README.md index 89f32721e..6c1a1a9dd 100644 --- a/packages/apps/braspag/README.md +++ b/packages/apps/braspag/README.md @@ -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/`. diff --git a/packages/apps/braspag/assets/braspag-onload-expression.js b/packages/apps/braspag/assets/braspag-onload-expression.js index 267d8ef32..661ffe76b 100644 --- a/packages/apps/braspag/assets/braspag-onload-expression.js +++ b/packages/apps/braspag/assets/braspag-onload-expression.js @@ -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 !== '') { @@ -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); diff --git a/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs b/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs index 9f1a03612..ebd666b9d 100644 --- a/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs +++ b/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs @@ -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 }) => { @@ -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 @@ -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; diff --git a/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs b/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs index 2602df732..c830daf0f 100644 --- a/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs +++ b/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs @@ -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)); @@ -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: [], }; @@ -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'), diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/3ds/get-3ds-token.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/3ds/get-3ds-token.mjs new file mode 100644 index 000000000..e9f3ab3dd --- /dev/null +++ b/packages/apps/braspag/lib-mjs/lib/braspag/3ds/get-3ds-token.mjs @@ -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; diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs new file mode 100644 index 000000000..dac6b6bd6 --- /dev/null +++ b/packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs @@ -0,0 +1,110 @@ +/* + * 3DS rules, isolated from the authentication script so the MPI can be + * replaced (Cielo will retire MPI V2 for V3) without touching authorization: + * on both versions the authorization receives the same Cavv, Xid, Eci, + * Version and ReferenceId. + */ + +/** + * ECI values of authenticated (or attempted) transactions with liability + * shift, from Cielo docs: Visa, Elo and Amex 05/06, Mastercard 02/01. The + * union is safe: Visa never returns 01/02 and Mastercard never 05/06. + */ +export const AUTHENTICATED_ECI = ['01', '02', '05', '06']; + +export const REFUSAL_MESSAGE = 'O banco não autenticou esta compra no cartão (3DS). ' + + 'Tente novamente ou pague com Pix ou boleto.'; + +/** + * Status set by the browser script (`status3ds` on the card hash). + */ +const STATUS_LABELS = { + authenticated: 'autenticado', + failure: 'desafio não concluído', + unenrolled: 'cartão não elegível', + disabled: 'autenticação desabilitada', + error: 'erro na autenticação', + unsupported_brand: 'bandeira sem 3DS', + timeout: 'tempo esgotado', + script_error: 'script do 3DS não carregou', + not_performed: 'não realizado', +}; + +export const get3dsOptions = (appData) => { + const options = appData?.braspag_3ds || {}; + const isRequired = Boolean(options.required); + let timeout = Number(options.timeout); + if (!timeout || Number.isNaN(timeout)) { + // A challenge may require opening the bank app or typing an SMS code + timeout = isRequired ? 300 : 30; + } + return { + hasCredentials: Boolean(options.client_id && options.client_secret), + isRequired, + timeoutMs: Math.min(Math.max(timeout, 30), 900) * 1000, + hasFraudAnalysis: Boolean(options.fraud_analysis), + }; +}; + +export const parse3dsResult = (out3ds, status3ds) => { + if (!out3ds || typeof out3ds !== 'object') { + return { + isAuthenticated: false, + status: status3ds && STATUS_LABELS[status3ds] ? status3ds : 'not_performed', + }; + } + const eci = out3ds.Eci !== undefined && out3ds.Eci !== null + ? String(out3ds.Eci).padStart(2, '0') + : ''; + const isAuthenticated = Boolean( + out3ds.Cavv + && out3ds.ReferenceId + && AUTHENTICATED_ECI.includes(eci), + ); + return { + isAuthenticated, + status: isAuthenticated ? 'authenticated' : (status3ds || 'error'), + eci, + version: out3ds.Version ? String(out3ds.Version) : '', + referenceId: out3ds.ReferenceId ? String(out3ds.ReferenceId) : '', + }; +}; + +/** + * Only the fields Cielo expects on `Payment.ExternalAuthentication`: the hash + * comes from the browser and must not inject anything else in the request. + */ +export const toExternalAuthentication = (out3ds) => { + const externalAuthentication = {}; + ['Cavv', 'Xid', 'Eci', 'Version', 'ReferenceId'].forEach((field) => { + if (out3ds[field] !== undefined && out3ds[field] !== null && out3ds[field] !== '') { + externalAuthentication[field] = String(out3ds[field]); + } + }); + return externalAuthentication; +}; + +/** + * Transaction custom fields shown on the order (admin), so the merchant no + * longer checks 3DS by hand. + */ +export const to3dsCustomFields = (result) => { + const fields = [{ + field: '3ds', + value: result.isAuthenticated + ? 'autenticado' + : `não autenticado (${STATUS_LABELS[result.status] || result.status})`, + }]; + if (result.eci) fields.push({ field: '3ds_eci', value: result.eci }); + if (result.version) fields.push({ field: '3ds_versao', value: result.version }); + if (result.referenceId) fields.push({ field: '3ds_referencia', value: result.referenceId }); + return fields; +}; + +export class Required3dsError extends Error { + constructor(result) { + super(REFUSAL_MESSAGE); + this.name = 'Required3dsError'; + this.result = result; + } +} diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/payload-to-transaction.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/payload-to-transaction.mjs index 245428471..7cd02b642 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/payload-to-transaction.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/payload-to-transaction.mjs @@ -1,4 +1,10 @@ import { parseAddress, parsePaymentType } from './parse-utils.mjs'; +import { + get3dsOptions, + parse3dsResult, + toExternalAuthentication, + Required3dsError, +} from './3ds/policy.mjs'; const parseFraudAnalysis = (appData, params, Address, fingerPrintId) => { const { amount, buyer, items } = params; @@ -102,17 +108,33 @@ const parseToTransaction = (appData, orderId, params, methodPayment) => { Object.assign(body.Customer, { DeliveryAddress: Address }); const fraudAnalysis = parseFraudAnalysis(appData, params, Address, hashCard.fingerPrintId); - Object.assign( - body.Payment, - { - Installments: installmentsNumber, - CreditCard: { - PaymentToken: hashCard.token, - }, + const options3ds = get3dsOptions(appData); + const result3ds = parse3dsResult(hashCard.out3ds, hashCard.status3ds); + if (options3ds.isRequired && !result3ds.isAuthenticated) { + // Required 3DS: never send a card transaction without authentication + throw new Required3dsError(result3ds); + } + Object.assign(body.Payment, { + Installments: installmentsNumber, + CreditCard: { + PaymentToken: hashCard.token, + }, + }); + if (result3ds.isAuthenticated) { + Object.assign(body.Payment, { + Authenticate: true, + ExternalAuthentication: toExternalAuthentication(hashCard.out3ds), + }); + } + if (result3ds.isAuthenticated && !options3ds.hasFraudAnalysis) { + // Authenticated, liability shifted to the issuer: capture right away + body.Payment.Capture = true; + } else { + Object.assign(body.Payment, { Capture: !fraudAnalysis.CaptureOnLowRisk, FraudAnalysis: fraudAnalysis, - }, - ); + }); + } } else if (methodPayment === 'account_deposit') { if (isCielo) { delete body.Payment.Provider; diff --git a/packages/apps/braspag/tests-unit/3ds.test.mjs b/packages/apps/braspag/tests-unit/3ds.test.mjs new file mode 100644 index 000000000..fba4ff8ee --- /dev/null +++ b/packages/apps/braspag/tests-unit/3ds.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert'; +import test, { describe } from 'node:test'; +import { + get3dsOptions, + parse3dsResult, + to3dsCustomFields, + REFUSAL_MESSAGE, +} from '../lib-mjs/lib/braspag/3ds/policy.mjs'; +import parseToTransaction from '../lib-mjs/lib/braspag/payload-to-transaction.mjs'; + +const authenticated = { + Cavv: 'AAABBBCCC=', Xid: 'xid', Eci: '05', Version: '2.2.0', ReferenceId: 'ref-1', +}; + +const params = (hashData) => ({ + amount: { total: 2999 }, + buyer: { + fullname: 'Fulano de Tal', doc_number: '12345678909', registry_type: 'p', email: 'f@example.com', + }, + to: { + street: 'Av. Anhanguera', + number: 7096, + borough: 'Setor dos Funcionários', + city: 'Goiânia', + province_code: 'GO', + zip: '74543010', + }, + items: [{ + product_id: 'p1', sku: '868', name: 'Split', quantity: 1, price: 2999, + }], + installments_number: 1, + credit_card: { hash: Buffer.from(JSON.stringify({ token: 'tok', fingerPrintId: 'fp', ...hashData })).toString('base64') }, +}); + +const appData = (braspag3ds) => ({ + credit_card: { provider: 'Cielo30' }, + braspag_3ds: braspag3ds, +}); + +describe('3DS policy', () => { + test('accepts authenticated and attempted ECI of each brand', () => { + ['05', '06', '02', '01', 5].forEach((Eci) => { + assert.strictEqual(parse3dsResult({ ...authenticated, Eci }).isAuthenticated, true, `ECI ${Eci}`); + }); + }); + + test('refuses non authenticated ECI and missing CAVV or ReferenceId', () => { + assert.strictEqual(parse3dsResult({ ...authenticated, Eci: '07' }).isAuthenticated, false); + assert.strictEqual(parse3dsResult({ ...authenticated, Eci: '00' }).isAuthenticated, false); + assert.strictEqual(parse3dsResult({ ...authenticated, Cavv: '' }).isAuthenticated, false); + const noReference = { ...authenticated, ReferenceId: null }; + assert.strictEqual(parse3dsResult(noReference).isAuthenticated, false); + assert.strictEqual(parse3dsResult(undefined, 'timeout').status, 'timeout'); + }); + + test('timeout is configurable, bigger when 3DS is required, within 30 s and 15 min', () => { + assert.strictEqual(get3dsOptions(appData({ required: true })).timeoutMs, 300000); + assert.strictEqual(get3dsOptions(appData({})).timeoutMs, 30000); + assert.strictEqual(get3dsOptions(appData({ timeout: 600 })).timeoutMs, 600000); + assert.strictEqual(get3dsOptions(appData({ timeout: 5 })).timeoutMs, 30000); + assert.strictEqual(get3dsOptions(appData({ timeout: 99999 })).timeoutMs, 900000); + }); + + test('custom fields show the result on the order', () => { + assert.deepStrictEqual(to3dsCustomFields(parse3dsResult(authenticated, 'authenticated')), [ + { field: '3ds', value: 'autenticado' }, + { field: '3ds_eci', value: '05' }, + { field: '3ds_versao', value: '2.2.0' }, + { field: '3ds_referencia', value: 'ref-1' }, + ]); + assert.deepStrictEqual(to3dsCustomFields(parse3dsResult(undefined, 'failure')), [ + { field: '3ds', value: 'não autenticado (desafio não concluído)' }, + ]); + }); +}); + +describe('Card transaction with 3DS', () => { + test('required 3DS refuses before calling Cielo when not authenticated', () => { + ['failure', 'unenrolled', 'unsupported_brand', 'timeout', 'script_error', undefined].forEach((status3ds) => { + assert.throws( + () => parseToTransaction(appData({ required: true }), 'o1', params({ status3ds }), 'credit_card'), + (err) => err.name === 'Required3dsError' && err.message === REFUSAL_MESSAGE, + `status ${status3ds}`, + ); + }); + }); + + test('authenticated card goes with external authentication and capture', () => { + const body = parseToTransaction( + appData({ required: true }), + 'o1', + params({ out3ds: { ...authenticated, Extra: 'x' }, status3ds: 'authenticated' }), + 'credit_card', + ); + assert.strictEqual(body.Payment.Authenticate, true); + assert.deepStrictEqual(body.Payment.ExternalAuthentication, { + Cavv: 'AAABBBCCC=', Xid: 'xid', Eci: '05', Version: '2.2.0', ReferenceId: 'ref-1', + }); + assert.strictEqual(body.Payment.Capture, true); + assert.strictEqual(body.Payment.FraudAnalysis, undefined); + }); + + test('fraud analysis can be kept on authenticated cards', () => { + const body = parseToTransaction( + appData({ required: true, fraud_analysis: true }), + 'o1', + params({ out3ds: authenticated, status3ds: 'authenticated' }), + 'credit_card', + ); + assert.strictEqual(body.Payment.Authenticate, true); + assert.ok(body.Payment.FraudAnalysis); + }); + + test('optional 3DS keeps legacy behavior: unauthenticated goes with fraud analysis', () => { + const body = parseToTransaction(appData({}), 'o1', params({ status3ds: 'failure' }), 'credit_card'); + assert.strictEqual(body.Payment.Authenticate, undefined); + assert.ok(body.Payment.FraudAnalysis); + }); +});