Skip to content

Commit

Permalink
feat(3ds): enhance 3DS example and secure payment workflow (#2)
Browse files Browse the repository at this point in the history
* feat(3ds): enhance 3DS example and secure payment workflow

- Update 3DS example with dynamic card selection and amount calculation
- Improve secure payment handling with more robust 3DS method processing
- Add support for forceNo3DS configuration in payment schemas
- Introduce generateOrderNumber utility for unique order tracking
- Refactor secure payment methods to handle various 3DS scenarios
- Update test card fixtures with comprehensive 3DS test scenarios

* refactor: standardize order number generation and enhance test workflows

- Replace `randomUUID()` with `generateOrderNumber()` across test and example files
- Add `forceNo3DS` parameter to payment tests for consistent 3DS handling
- Improve certificate content handling in integration tests
- Update example scripts to use test card fixtures and dynamic order numbers
  • Loading branch information
gustavovalverde authored Feb 14, 2025
1 parent a898ec8 commit 3b9de26
Show file tree
Hide file tree
Showing 21 changed files with 467 additions and 222 deletions.
259 changes: 177 additions & 82 deletions examples/3ds-example.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,37 @@
import express from 'express';
import AzulAPI from '../src/azul-api/api';
import { ChallengeIndicator } from '../src/azul-api/secure/types';
import { TEST_CARDS } from '../tests/fixtures/cards';
import { generateOrderNumber } from '../tests/fixtures/order';
import 'dotenv/config';

const app = express();
app.use(express.urlencoded({ extended: true }));

const CARDS = [
{
value: '4149011500000519',
label: '3D Secure Method con desafío'
number: TEST_CARDS.SECURE_3DS_FRICTIONLESS_WITH_3DS.number,
label: 'Sin fricción con 3DSMethod',
expiration: TEST_CARDS.SECURE_3DS_FRICTIONLESS_WITH_3DS.expiration,
cvv: TEST_CARDS.SECURE_3DS_FRICTIONLESS_WITH_3DS.cvv
},
{
value: '4265880000000007',
label: 'Sin fricción con 3DSMethod'
number: TEST_CARDS.SECURE_3DS_FRICTIONLESS_NO_3DS.number,
label: 'Sin fricción sin 3DS', // Correct label
expiration: TEST_CARDS.SECURE_3DS_FRICTIONLESS_NO_3DS.expiration,
cvv: TEST_CARDS.SECURE_3DS_FRICTIONLESS_NO_3DS.cvv
},
{
value: '4147463011110117',
label: 'Sin fricción sin 3DSMethod'
number: TEST_CARDS.SECURE_3DS_CHALLENGE_WITH_3DS.number,
label: 'Desafío con 3DSMethod (Limite RD$ 50)',
expiration: TEST_CARDS.SECURE_3DS_CHALLENGE_WITH_3DS.expiration,
cvv: TEST_CARDS.SECURE_3DS_CHALLENGE_WITH_3DS.cvv
},
{
value: '4005520000000129',
label: 'Desafío con 3DSMethod'
},
{
value: '4147463011110059',
label: 'Desafío sin 3DSMethod'
},
{
value: '4149011500000527',
label: 'Desafío'
number: TEST_CARDS.SECURE_3DS_CHALLENGE_NO_3DS.number,
label: 'Desafío sin 3DSMethod',
expiration: TEST_CARDS.SECURE_3DS_CHALLENGE_NO_3DS.expiration,
cvv: TEST_CARDS.SECURE_3DS_CHALLENGE_NO_3DS.cvv
}
];

Expand All @@ -41,103 +43,196 @@ const azul = new AzulAPI({
key: process.env.AZUL_KEY!
});

// Configuration for channel, posInputMode, and acquirerRefData
const SALE_CONFIG = {
channel: 'EC', // E-Commerce channel
posInputMode: 'E-Commerce',
acquirerRefData: '1' as '0' | '1', // Type assertion to match the enum type
forceNo3DS: '0' as '0' | '1' // Add forceNo3DS here, default to '0' (3DS enabled)
};

app.get('/', (req, res) => {
res.send(
`
<h1>3DS Example</h1>
<h2>Choose a card</h2>
${CARDS.map(
(card) => `<a href="/buy?cardNumber=${card.value}"><button>${card.label}</button></a>`
).join('<br><br>')}
<form action="/buy" method="post">
<label for="card">Card:</label>
<select name="card" id="card">
${CARDS.map((card) => `<option value="${card.number}">${card.label}</option>`)}
</select>
<br>
<label for="amount">Amount:</label>
<input type="number" name="amount" id="amount" value="10.00" step="0.01">
<br>
<label for="itbis">ITBIS:</label>
<input type="number" name="itbis" id="itbis" value="1.80" step="0.01" readonly>
<br><br>
<button type="submit">Buy</button>
</form>
<script>
const amountInput = document.getElementById('amount');
const itbisInput = document.getElementById('itbis');
amountInput.addEventListener('input', () => {
const amount = parseFloat(amountInput.value);
const itbis = amount * 0.18;
itbisInput.value = itbis.toFixed(2);
});
// Trigger the calculation on initial load
amountInput.dispatchEvent(new Event('input'));
</script>
`
);
});

app.get('/buy', async (req, res) => {
const cardNumber = req.query.cardNumber;
app.post('/buy', async (req, res) => {
const { card, amount, itbis } = req.body;

if (typeof cardNumber !== 'string') {
return res.status(400).send('Invalid card number');
const selectedCard = CARDS.find((c) => c.number === card);
if (!selectedCard) {
return res.status(400).send('Invalid card selected');
}

const result = await azul.secure.sale({
cardNumber,
expiration: '202412',
CVC: '818',
customOrderId: '1234',
amount: 1000,
ITBIS: 100,
browserInfo: {
AcceptHeader:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signedexchange;v=b3;q=0.9',
IPAddress: '127.0.0.1',
Language: 'en-US',
ColorDepth: '24',
ScreenWidth: '2880',
ScreenHeight: '1800',
TimeZone: '240',
UserAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36',
JavaScriptEnabled: true
},
cardHolderInfo: {
BillingAddressCity: 'Ciudad Facturación',
BillingAddressCountry: 'País Facturación',
BillingAddressLine1: 'Línea 1 Dirección Facturación',
BillingAddressLine2: 'Línea 2 Dirección Facturación',
BillingAddressLine3: 'Línea 3 Dirección Facturación',
BillingAddressState: 'Estado o Provincia Facturación',
BillingAddressZip: '99999',
Email: 'correo@dominio.com',
Name: 'Nombre Tarjetahabiente',
PhoneHome: '8099999999',
PhoneMobile: '8299999999',
PhoneWork: '8499999999',
ShippingAddressCity: 'Ciudad Envío',
ShippingAddressCountry: 'País Envío',
ShippingAddressLine1: 'Línea 1 Dirección Envío',
ShippingAddressLine2: 'Línea 2 Dirección Envío',
ShippingAddressLine3: 'Línea 3 Dirección Envío',
ShippingAddressState: 'Estado o Provincia Facturación',
ShippingAddressZip: '99999'
},
threeDSAuth: {
TermUrl: 'http://localhost:3000/post-3ds',
MethodNotificationUrl: 'http://localhost:3000/capture-3ds',
RequestorChallengeIndicator: ChallengeIndicator.NO_PREFERENCE
}
});
const orderId = generateOrderNumber();

if (result.redirect) {
res.send(result.html);
} else {
if (result.value.IsoCode === '00') {
res.send(result.value);
// Determine ForceNo3DS and RequestorChallengeIndicator based on the selected card
let forceNo3DS = '0' as '0' | '1';
let requestorChallengeIndicator = ChallengeIndicator.NO_PREFERENCE;

if (selectedCard.label.includes('Sin fricción sin 3DS')) {
forceNo3DS = '1';
requestorChallengeIndicator = ChallengeIndicator.NO_CHALLENGE;
} else if (selectedCard.label.includes('Sin fricción con 3DSMethod')) {
forceNo3DS = '0';
requestorChallengeIndicator = ChallengeIndicator.NO_CHALLENGE;
} else if (selectedCard.label.includes('Desafío sin 3DSMethod')) {
forceNo3DS = '1';
requestorChallengeIndicator = ChallengeIndicator.CHALLENGE;
} else if (selectedCard.label.includes('Desafío con 3DSMethod')) {
forceNo3DS = '0';
requestorChallengeIndicator = ChallengeIndicator.CHALLENGE;
}

try {
const result = await azul.secure.sale({
amount: Math.round(Number(amount) * 100),
ITBIS: Math.round(Number(itbis) * 100),
cardNumber: selectedCard.number,
CVC: selectedCard.cvv,
expiration: selectedCard.expiration,
orderNumber: orderId,
channel: SALE_CONFIG.channel,
posInputMode: SALE_CONFIG.posInputMode,
acquirerRefData: SALE_CONFIG.acquirerRefData,
forceNo3DS: forceNo3DS, // Pass forceNo3DS here
cardHolderInfo: {
BillingAddressCity: 'Ciudad Facturación',
BillingAddressCountry: 'País Facturación',
BillingAddressLine1: 'Línea 1 Dirección Facturación',
BillingAddressLine2: 'Línea 2 Dirección Facturación',
BillingAddressLine3: 'Línea 3 Dirección Facturación',
BillingAddressState: 'Estado o Provincia Facturación',
BillingAddressZip: '99999',
Email: 'correo@dominio.com',
Name: 'Nombre Tarjetahabiente',
PhoneHome: '8099999999',
PhoneMobile: '8299999999',
PhoneWork: '8499999999',
ShippingAddressCity: 'Ciudad Envío',
ShippingAddressCountry: 'País Envío',
ShippingAddressLine1: 'Línea 1 Dirección Envío',
ShippingAddressLine2: 'Línea 2 Dirección Envío',
ShippingAddressLine3: 'Línea 3 Dirección Envío',
ShippingAddressState: 'Estado o Provincia Facturación',
ShippingAddressZip: '99999'
},
threeDSAuth: {
RequestorChallengeIndicator: requestorChallengeIndicator,
MethodNotificationUrl: `http://localhost:3000/capture-3ds?id=${orderId}`,
TermUrl: `http://localhost:3000/post-3ds?id=${orderId}`
}
});

if (result.redirect) {
res.send(`${result.html} <p>Redirecting for 3DS Challenge...</p>`);
} else {
res.status(500).send('Error');
const isoCode = result.value.IsoCode;
const responseMessage = result.value.ResponseMessage;

if (isoCode === '00' && responseMessage === 'APROBADA') {
res.send(`<p>Transaction Approved!</p><pre>${JSON.stringify(result.value, null, 2)}</pre>`);
} else {
const message = result.value.Message || 'Unknown error';
res.status(500).send(`<p>Error processing transaction: ${isoCode} - ${message}</p>`);
}
}
} catch (error) {
console.error('Error processing transaction:', error);
res.status(500).send('<p>Error processing transaction: - Unknown error</p>');
}
});

app.post('/post-3ds', async (req, res) => {
const { id } = req.query;
const { cres } = req.body;

if (typeof id !== 'string' || typeof cres !== 'string') {
// Check if id is an array and get the second element
let secureId: string;
if (Array.isArray(id)) {
if (id.length < 2) {
return res.status(400).send('Invalid ID: Secure ID missing');
}
secureId = String(id[1]);
} else if (typeof id === 'string') {
//This should never happen now
return res.status(400).send('Invalid ID: Secure ID missing');
} else {
return res.status(400).send('Invalid ID');
}

res.send(await azul.secure.post3DS(id, cres));
try {
const result = await azul.secure.post3DS(secureId, cres);
res.send(`<p>Transaction Result:</p><pre>${JSON.stringify(result, null, 2)}</pre>`);
} catch (error) {
console.error('Error during post3DS:', error);
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
res.status(500).send(`<p>An error occurred: ${errorMessage}</p>`);
}
});

app.post('/capture-3ds', async (req, res) => {
const id = req.query.id;
console.log('ID', id);

if (typeof id !== 'string') {
// Check if id is an array and get the second element
let secureId: string;
if (Array.isArray(id)) {
if (id.length < 2) {
return res.status(400).send('Invalid ID: Secure ID missing');
}
secureId = String(id[1]);
} else if (typeof id === 'string') {
//This should never happen now
return res.status(400).send('Invalid ID: Secure ID missing');
} else {
return res.status(400).send('Invalid ID');
}

res.send(await azul.secure.capture3DS(id));
try {
const result = await azul.secure.capture3DS(secureId);
if (result.redirect) {
res.send(`${result.html} <p>Redirecting for 3DS Challenge...</p>`);
} else {
res.send(`<p>Transaction Result:</p><pre>${JSON.stringify(result.value, null, 2)}</pre>`);
}
} catch (error) {
console.error('Error during capture3DS:', error);
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
res.status(500).send(`<p>An error occurred: ${errorMessage}</p>`);
}
});

app.listen(3000);
app.listen(3000, () => {
console.log('3DS Example app listening on port 3000');
});
12 changes: 8 additions & 4 deletions examples/api-example.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import express from 'express';
import AzulAPI from '../src/azul-api/api';
import { TEST_CARDS } from '../tests/fixtures/cards';
import { generateOrderNumber } from '../tests/fixtures/order';
import 'dotenv/config';

const app = express();
Expand All @@ -13,11 +15,13 @@ const azul = new AzulAPI({
});

app.get('/buy-ticket', async (req, res) => {
const card = TEST_CARDS.VISA_1;

const result = await azul.payments.sale({
cardNumber: '6011000990099818',
expiration: '202412',
CVC: '818',
customOrderId: '1234',
cardNumber: card.number,
expiration: card.expiration,
CVC: card.cvv,
customOrderId: generateOrderNumber(),
amount: 1000,
ITBIS: 100
});
Expand Down
4 changes: 2 additions & 2 deletions examples/page-example.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import express from 'express';
import AzulPage from '../src/azul-page';

import { generateOrderNumber } from '../tests/fixtures/order';
const app = express();

const azul = new AzulPage({
Expand All @@ -22,7 +22,7 @@ app.get('/', (req, res) => {
app.get('/buy-ticket', async (req, res) => {
res.send(
azul.createForm({
orderNumber: '1234',
orderNumber: generateOrderNumber(),
amount: 1000,
ITBIS: 100,
approvedUrl: 'https://rapidotickets.com/',
Expand Down
4 changes: 2 additions & 2 deletions examples/simple-payment.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import 'dotenv/config';
import AzulAPI from '../src/azul-api/api';
import { randomUUID } from 'crypto';
import { generateOrderNumber } from '../tests/fixtures/order';
import { getCard } from '../tests/fixtures/cards';

/**
Expand All @@ -20,7 +20,7 @@ async function processPayment() {

// Test card details
const testCard = getCard('VISA_TEST_CARD');
const orderId = randomUUID();
const orderId = generateOrderNumber();
const amount = 100; // RD$100
const ITBIS = 10; // RD$10

Expand Down
Loading

0 comments on commit 3b9de26

Please sign in to comment.