From 220eb5fac203d064e66fb2f29cff5fdcd173ffb7 Mon Sep 17 00:00:00 2001 From: aqudsilva_meli Date: Tue, 28 Oct 2025 17:48:00 -0300 Subject: [PATCH] feat: adding 3ds feature --- e2e/order/create3DS.spec.ts | 242 ++++++++++++++++++++++++ e2e/order/get3DS.spec.ts | 165 ++++++++++++++++ src/clients/order/commonTypes.ts | 21 ++ src/clients/order/create/types.ts | 3 +- src/examples/order/createWith3DS.ts | 70 +++++++ src/examples/order/handle3DSResponse.ts | 136 +++++++++++++ 6 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 e2e/order/create3DS.spec.ts create mode 100644 e2e/order/get3DS.spec.ts create mode 100644 src/examples/order/createWith3DS.ts create mode 100644 src/examples/order/handle3DSResponse.ts diff --git a/e2e/order/create3DS.spec.ts b/e2e/order/create3DS.spec.ts new file mode 100644 index 0000000..65d796b --- /dev/null +++ b/e2e/order/create3DS.spec.ts @@ -0,0 +1,242 @@ +import MercadoPago from '@src/index'; +import { config } from '../e2e.config'; +import { Order } from '@src/clients/order'; +import { OrderCreateData } from '@src/clients/order/create/types'; + +describe('Create Order with 3DS integration test', () => { + let mercadoPagoConfig: MercadoPago; + let order: Order; + + beforeEach(() => { + mercadoPagoConfig = new MercadoPago({ accessToken: config.access_token }); + order = new Order(mercadoPagoConfig); + }); + + test('should create Order with 3DS on_fraud_risk validation', async () => { + const body: OrderCreateData = { + body: { + type: 'online', + processing_mode: 'automatic', + total_amount: '150.00', + external_reference: '3ds_test_fraud_risk', + config: { + online: { + transaction_security: { + validation: 'on_fraud_risk', + liability_shift: 'required' + } + } + }, + transactions: { + payments: [ + { + amount: '150.00', + payment_method: { + id: 'master', + type: 'credit_card', + token: 'CARD_TOKEN', // This should be replaced with a valid test token + installments: 1 + } + } + ] + }, + payer: { + email: 'test_3ds@testuser.com', + identification: { + type: 'CPF', + number: '12345678901' + } + } + } + }; + + const response = await order.create(body); + + expect(response.id).toBeTruthy(); + expect(response.type).toBe('online'); + expect(response.total_amount).toBe('150.00'); + expect(response.external_reference).toBe('3ds_test_fraud_risk'); + + // Verify 3DS configuration + expect(response.config?.online?.transaction_security?.validation).toBe('on_fraud_risk'); + expect(response.config?.online?.transaction_security?.liability_shift).toBe('required'); + + // Verify payment structure + const payment = response.transactions?.payments?.[0]; + expect(payment).toBeTruthy(); + expect(payment?.amount).toBe('150.00'); + expect(payment?.payment_method?.id).toBe('master'); + expect(payment?.payment_method?.type).toBe('credit_card'); + + // Check if 3DS transaction security is present in payment method + if (payment?.payment_method?.transaction_security) { + expect(payment.payment_method.transaction_security.validation).toBe('on_fraud_risk'); + expect(payment.payment_method.transaction_security.liability_shift).toBe('required'); + } + }); + + test('should create Order with 3DS always validation', async () => { + const body: OrderCreateData = { + body: { + type: 'online', + processing_mode: 'automatic', + total_amount: '200.00', + external_reference: '3ds_test_always', + config: { + online: { + transaction_security: { + validation: 'always', + liability_shift: 'preferred' + } + } + }, + transactions: { + payments: [ + { + amount: '200.00', + payment_method: { + id: 'visa', + type: 'credit_card', + token: 'CARD_TOKEN', // This should be replaced with a valid test token + installments: 1 + } + } + ] + }, + payer: { + email: 'test_3ds_always@testuser.com', + identification: { + type: 'CPF', + number: '12345678901' + } + } + } + }; + + const response = await order.create(body); + + expect(response.id).toBeTruthy(); + expect(response.config?.online?.transaction_security?.validation).toBe('always'); + expect(response.config?.online?.transaction_security?.liability_shift).toBe('preferred'); + }); + + test('should create Order with 3DS never validation', async () => { + const body: OrderCreateData = { + body: { + type: 'online', + processing_mode: 'automatic', + total_amount: '100.00', + external_reference: '3ds_test_never', + config: { + online: { + transaction_security: { + validation: 'never', + liability_shift: 'required' + } + } + }, + transactions: { + payments: [ + { + amount: '100.00', + payment_method: { + id: 'master', + type: 'credit_card', + token: 'CARD_TOKEN', // This should be replaced with a valid test token + installments: 1 + } + } + ] + }, + payer: { + email: 'test_3ds_never@testuser.com', + identification: { + type: 'CPF', + number: '12345678901' + } + } + } + }; + + const response = await order.create(body); + + expect(response.id).toBeTruthy(); + expect(response.config?.online?.transaction_security?.validation).toBe('never'); + + // With 'never' validation, payment should be processed directly without 3DS + const payment = response.transactions?.payments?.[0]; + expect(payment?.status).toBeDefined(); + }); + + test('should handle 3DS challenge response correctly', async () => { + // First create an order that might require 3DS challenge + const createBody: OrderCreateData = { + body: { + type: 'online', + processing_mode: 'automatic', + total_amount: '150.00', + external_reference: '3ds_challenge_test', + config: { + online: { + transaction_security: { + validation: 'on_fraud_risk', + liability_shift: 'required' + } + } + }, + transactions: { + payments: [ + { + amount: '150.00', + payment_method: { + id: 'master', + type: 'credit_card', + token: 'CARD_TOKEN', // Use test token that triggers challenge + installments: 1 + } + } + ] + }, + payer: { + email: 'test_3ds_challenge@testuser.com', + identification: { + type: 'CPF', + number: '12345678901' + } + } + } + }; + + const createResponse = await order.create(createBody); + expect(createResponse.id).toBeTruthy(); + + // Get the order to check final status + const getResponse = await order.get({ id: createResponse.id! }); + + expect(getResponse.id).toBe(createResponse.id); + expect(getResponse.transactions?.payments).toBeDefined(); + + const payment = getResponse.transactions?.payments?.[0]; + expect(payment).toBeTruthy(); + + // Verify that 3DS information is preserved in the response + if (payment?.payment_method?.transaction_security) { + expect(['always', 'on_fraud_risk', 'never']).toContain( + payment.payment_method.transaction_security.validation + ); + expect(['required', 'preferred']).toContain( + payment.payment_method.transaction_security.liability_shift + ); + } + + // Check possible 3DS statuses + if (payment?.status === 'action_required' && payment?.status_detail === 'pending_challenge') { + // 3DS challenge is required + expect(payment.payment_method?.transaction_security?.url).toBeTruthy(); + console.log('3DS Challenge URL:', payment.payment_method?.transaction_security?.url); + } else if (payment?.status === 'processed') { + // Payment was processed without challenge or after successful challenge + expect(payment.status_detail).toBeDefined(); + } + }); +}); diff --git a/e2e/order/get3DS.spec.ts b/e2e/order/get3DS.spec.ts new file mode 100644 index 0000000..c408f4a --- /dev/null +++ b/e2e/order/get3DS.spec.ts @@ -0,0 +1,165 @@ +import MercadoPago from '@src/index'; +import { config } from '../e2e.config'; +import { Order } from '@src/clients/order'; + +describe('Get Order with 3DS integration test', () => { + let mercadoPagoConfig: MercadoPago; + let order: Order; + + beforeEach(() => { + mercadoPagoConfig = new MercadoPago({ accessToken: config.access_token }); + order = new Order(mercadoPagoConfig); + }); + + test('should get Order with 3DS transaction security information', async () => { + // This test assumes an existing order with 3DS was created + // In a real scenario, you would create the order first or use a known order ID + const orderId = 'ORDER_ID_WITH_3DS'; // Replace with actual order ID from previous test + + try { + const response = await order.get({ id: orderId }); + + expect(response.id).toBe(orderId); + expect(response.transactions?.payments).toBeDefined(); + + const payment = response.transactions?.payments?.[0]; + if (payment) { + // Verify payment structure + expect(payment.id).toBeTruthy(); + expect(payment.amount).toBeTruthy(); + expect(payment.payment_method).toBeTruthy(); + + // Check 3DS transaction security in payment method + if (payment.payment_method?.transaction_security) { + const transactionSecurity = payment.payment_method.transaction_security; + + // Verify 3DS validation types + expect(['always', 'on_fraud_risk', 'never']).toContain(transactionSecurity.validation); + + // Verify liability shift types + expect(['required', 'preferred']).toContain(transactionSecurity.liability_shift); + + // If challenge URL is present, verify it's a valid URL + if (transactionSecurity.url) { + expect(transactionSecurity.url).toMatch(/^https?:\/\/.+/); + } + + // Check additional 3DS fields + if (transactionSecurity.type) { + expect(typeof transactionSecurity.type).toBe('string'); + } + + if (transactionSecurity.status) { + expect(typeof transactionSecurity.status).toBe('string'); + } + } + + // Check different 3DS payment statuses + switch (payment.status) { + case 'action_required': + if (payment.status_detail === 'pending_challenge') { + expect(payment.payment_method?.transaction_security?.url).toBeTruthy(); + console.log('3DS Challenge required for order:', orderId); + } + break; + + case 'processed': + if (payment.status_detail === 'accredited') { + console.log('Payment processed successfully (3DS completed or not required)'); + } + break; + + case 'failed': + if (payment.status_detail === 'cc_rejected_3ds_challenge') { + console.log('Payment failed due to 3DS challenge failure'); + } + break; + + case 'cancelled': + if (payment.status_detail === 'expired') { + console.log('Payment cancelled due to 3DS timeout'); + } + break; + } + } + + // Check 3DS configuration in order config + if (response.config?.online?.transaction_security) { + const configSecurity = response.config.online.transaction_security; + expect(['always', 'on_fraud_risk', 'never']).toContain(configSecurity.validation); + expect(['required', 'preferred']).toContain(configSecurity.liability_shift); + } + + } catch (error) { + // If order doesn't exist, skip the test + if (error instanceof Error && error.message.includes('not found')) { + console.log('Skipping test - order not found. Create an order with 3DS first.'); + return; + } + throw error; + } + }); + + test('should handle different 3DS status scenarios', async () => { + // This is a conceptual test that demonstrates how to handle different 3DS scenarios + // In practice, you would need actual orders in different states + + const testScenarios = [ + { + name: 'Challenge Required', + expectedStatus: 'action_required', + expectedStatusDetail: 'pending_challenge', + shouldHaveChallengeUrl: true + }, + { + name: 'Payment Approved', + expectedStatus: 'processed', + expectedStatusDetail: 'accredited', + shouldHaveChallengeUrl: false + }, + { + name: '3DS Challenge Failed', + expectedStatus: 'failed', + expectedStatusDetail: 'cc_rejected_3ds_challenge', + shouldHaveChallengeUrl: false + }, + { + name: 'Challenge Expired', + expectedStatus: 'cancelled', + expectedStatusDetail: 'expired', + shouldHaveChallengeUrl: false + } + ]; + + // This is a mock test to demonstrate the expected behavior + // In real tests, you would have actual order IDs for each scenario + testScenarios.forEach(scenario => { + expect(scenario.name).toBeTruthy(); + expect(scenario.expectedStatus).toBeTruthy(); + expect(scenario.expectedStatusDetail).toBeTruthy(); + expect(typeof scenario.shouldHaveChallengeUrl).toBe('boolean'); + }); + }); + + test('should validate 3DS response structure', () => { + // Mock response structure validation + const mockPaymentMethod = { + id: 'master', + type: 'credit_card', + transaction_security: { + validation: 'on_fraud_risk' as const, + liability_shift: 'required' as const, + url: 'https://example.com/3ds-challenge', + type: '3DS', + status: 'pending' + } + }; + + // Validate the structure matches our TypeScript types + expect(mockPaymentMethod.transaction_security.validation).toBe('on_fraud_risk'); + expect(mockPaymentMethod.transaction_security.liability_shift).toBe('required'); + expect(mockPaymentMethod.transaction_security.url).toMatch(/^https?:\/\/.+/); + expect(typeof mockPaymentMethod.transaction_security.type).toBe('string'); + expect(typeof mockPaymentMethod.transaction_security.status).toBe('string'); + }); +}); diff --git a/src/clients/order/commonTypes.ts b/src/clients/order/commonTypes.ts index 6dd1b36..bfc6cb8 100644 --- a/src/clients/order/commonTypes.ts +++ b/src/clients/order/commonTypes.ts @@ -54,6 +54,7 @@ export declare type OnlineConfig = { failure_url?: string; auto_return_url?: string; differential_pricing?: DifferentialPricing; + transaction_security?: TransactionSecurity; } export declare type IntegrationDataResponse = { @@ -152,6 +153,7 @@ export declare type PaymentMethodResponse = { qr_code?: string; qr_code_base64?: string; digitable_line?: string; + transaction_security?: TransactionSecurityResponse; } export declare type RefundResponse = { @@ -204,3 +206,22 @@ export declare type PaymentMethodRequest = { installments?: number; statement_descriptor?: string; } + +/** + * 3DS Transaction Security types for requests + */ +export declare type TransactionSecurity = { + validation?: 'always' | 'on_fraud_risk' | 'never'; + liability_shift?: 'required' | 'preferred'; +} + +/** + * 3DS Transaction Security types for responses + */ +export declare type TransactionSecurityResponse = { + validation?: 'always' | 'on_fraud_risk' | 'never'; + liability_shift?: 'required' | 'preferred'; + url?: string; + type?: string; + status?: string; +} diff --git a/src/clients/order/create/types.ts b/src/clients/order/create/types.ts index 9798475..243c7db 100644 --- a/src/clients/order/create/types.ts +++ b/src/clients/order/create/types.ts @@ -1,7 +1,7 @@ import { Phone } from '../../../clients/commonTypes'; import { MercadoPagoConfig } from '../../../mercadoPagoConfig'; import { Options } from '../../../types'; -import { Address, AutomaticPayments, Config, Identification, Item, StoredCredential, SubscriptionData } from '../commonTypes'; +import { Address, AutomaticPayments, Config, Identification, Item, StoredCredential, SubscriptionData, TransactionSecurity } from '../commonTypes'; export declare type OrderCreateClient = { body: CreateOrderRequest; config: MercadoPagoConfig; @@ -45,6 +45,7 @@ export declare type PaymentMethodRequest = { token?: string; installments?: number; statement_descriptor?: string; + transaction_security?: TransactionSecurity; }; export declare type PayerRequest = { customer_id?: string; diff --git a/src/examples/order/createWith3DS.ts b/src/examples/order/createWith3DS.ts new file mode 100644 index 0000000..29b9408 --- /dev/null +++ b/src/examples/order/createWith3DS.ts @@ -0,0 +1,70 @@ +/** + * Mercado Pago Create Order with 3DS Authentication. + * + * This example demonstrates how to create an order with 3DS (3D Secure) authentication + * for enhanced security in credit card transactions. + * + * @see {@link https://mercadopago.com/developers/en/reference/orders/online-payments/create/post Documentation }. + */ + +import { Order } from '@src/clients/order'; +import MercadoPago from '@src/index'; + +const mercadoPagoConfig = new MercadoPago({ accessToken: '', options: { timeout: 5000 } }); + +const order = new Order(mercadoPagoConfig); + +order.create({ + body: { + type: 'online', + processing_mode: 'automatic', + capture_mode: 'automatic_async', + total_amount: '150.00', + external_reference: '3ds_test', + config: { + online: { + transaction_security: { + validation: 'on_fraud_risk', + liability_shift: 'required' + } + } + }, + transactions: { + payments: [ + { + amount: '150.00', + payment_method: { + id: 'master', + type: 'credit_card', + token: '', + installments: 1 + } + } + ] + }, + payer: { + email: '', + identification: { + type: 'CPF', + number: '00000000000' + } + } + }, + requestOptions: { + idempotencyKey: '' + } +}).then((response) => { + console.log('Order created successfully:', response); + + // Check if 3DS challenge is required + const payment = response.transactions?.payments?.[0]; + if (payment?.status === 'action_required' && payment?.status_detail === 'pending_challenge') { + const challengeUrl = payment.payment_method?.transaction_security?.url; + if (challengeUrl) { + console.log('3DS Challenge required. Challenge URL:', challengeUrl); + console.log('Display this URL in an iframe to complete the 3DS authentication'); + } + } else if (payment?.status === 'processed') { + console.log('Payment approved without 3DS challenge'); + } +}).catch(console.error); diff --git a/src/examples/order/handle3DSResponse.ts b/src/examples/order/handle3DSResponse.ts new file mode 100644 index 0000000..75fb16b --- /dev/null +++ b/src/examples/order/handle3DSResponse.ts @@ -0,0 +1,136 @@ +/** + * Mercado Pago Handle 3DS Response Example. + * + * This example demonstrates how to handle different 3DS authentication scenarios + * and check the status of transactions after 3DS challenge completion. + * + * @see {@link https://mercadopago.com/developers/en/reference/orders/online-payments/get/get Documentation }. + */ + +import { Order } from '@src/clients/order'; +import MercadoPago from '@src/index'; + +const mercadoPagoConfig = new MercadoPago({ accessToken: '', options: { timeout: 5000 } }); + +const order = new Order(mercadoPagoConfig); + +/** + * Function to handle 3DS response and check payment status + */ +async function handle3DSResponse(orderId: string): Promise { + try { + const response = await order.get({ id: orderId }); + + console.log('Order status:', response.status); + console.log('Order status detail:', response.status_detail); + + const payment = response.transactions?.payments?.[0]; + + if (!payment) { + console.log('No payment found in order'); + return; + } + + console.log('Payment status:', payment.status); + console.log('Payment status detail:', payment.status_detail); + + // Handle different 3DS scenarios + switch (payment.status) { + case 'processed': + if (payment.status_detail === 'accredited') { + console.log('✅ Payment approved successfully (with or without 3DS)'); + } + break; + + case 'action_required': + if (payment.status_detail === 'pending_challenge') { + const challengeUrl = payment.payment_method?.transaction_security?.url; + console.log('⏳ 3DS Challenge required'); + console.log('Challenge URL:', challengeUrl); + console.log('Display this URL in an iframe and listen for completion events'); + } + break; + + case 'failed': + switch (payment.status_detail) { + case 'cc_rejected_3ds_challenge': + console.log('❌ Payment rejected due to 3DS challenge failure'); + break; + case 'failed': + console.log('❌ Payment rejected without authentication'); + break; + default: + console.log('❌ Payment failed:', payment.status_detail); + } + break; + + case 'cancelled': + if (payment.status_detail === 'expired') { + console.log('⏰ Payment cancelled - 3DS challenge expired (5 minutes timeout)'); + } + break; + + default: + console.log('🔄 Payment in progress:', payment.status); + } + + // Display 3DS security information if available + const transactionSecurity = payment.payment_method?.transaction_security; + if (transactionSecurity) { + console.log('\n3DS Security Information:'); + console.log('- Validation:', transactionSecurity.validation); + console.log('- Liability Shift:', transactionSecurity.liability_shift); + console.log('- Status:', transactionSecurity.status); + console.log('- Type:', transactionSecurity.type); + } + + } catch (error) { + console.error('Error checking order status:', error); + } +} + +// Example usage +const orderId = ''; +handle3DSResponse(orderId); + +/** + * Example of JavaScript code to handle iframe events on the frontend + */ +const frontendIframeHandler = ` +// Add this to your frontend to handle 3DS iframe completion +window.addEventListener("message", (e) => { + if (e.data.status === "COMPLETE") { + // 3DS challenge completed - redirect to confirmation page + window.open("congrats.html"); + + // Or make a request to check the final payment status + checkPaymentStatus(); + } +}); + +async function checkPaymentStatus() { + const orderId = localStorage.getItem("orderId"); + try { + const response = await fetch(\`/api/orders/\${orderId}\`, { + method: "GET", + }); + const result = await response.json(); + + if (result.status === "processed") { + // Payment successful + showSuccessMessage(result); + } else if (result.status === "failed") { + // Payment failed + showErrorMessage(result); + } else { + // Still processing - retry after a delay + setTimeout(checkPaymentStatus, 2000); + } + } catch (error) { + console.error("Error checking payment status:", error); + } +} +`; + +console.log('\nFrontend iframe handler code:'); +console.log(frontendIframeHandler);