/* © 2018 NauStud.io
* @author Eric Tran
*/
import SimpleSchema from 'simpl-schema';
import fetch from 'node-fetch';
import { parseString } from 'xml2js';
import { createMd5Hash } from '../utils';
/**
* NganLuong payment gateway helper
* <br>
* _Hàm hỗ trợ thanh toán qua Ngân Lượng_
*
* @example
* import { NganLuong } from 'vn-payments';
*
* const TEST_CONFIG = NganLuong.TEST_CONFIG;
*
* const nganluongCheckout = new NganLuong({
* paymentGateway: TEST_CONFIG.paymentGateway,
* merchant: TEST_CONFIG.merchant,
* receiverEmail: TEST_CONFIG.receiverEmail,
* secureSecret: TEST_CONFIG.secureSecret,
* });
*
* // checkoutUrl is an URL instance
* const checkoutUrl = await nganluongCheckout.buildCheckoutUrl(params);
*
* this.response.writeHead(301, { Location: checkoutUrl.href });
* this.response.end();
*/
class NganLuong {
/**
* Instantiate a NganLuong checkout helper
* <br>
* _Khởi tạo hàm thanh toán NganLuong_
*
* @param {Object} config check NganLuong.configSchema for data type requirements <br> _Xem NganLuong.configSchema để biết yêu cầu kiểu dữ liệu_
* @return {void}
*/
constructor(config) {
this.config = Object.assign({}, config);
// check type validity
NganLuong.configSchema.validate(this.config);
}
/**
* Build checkoutUrl to redirect to the payment gateway
* <br>
* _Hàm xây dựng url để redirect qua NganLuong gateway, trong đó có tham số mã hóa (còn gọi là public key)_
*
* @param {NganLuongCheckoutPayload} payload Object that contains needed data for the URL builder, refer to typeCheck object above <br> _Đối tượng chứa các dữ liệu cần thiết để thiết lập đường dẫn._
* @return {Promise<URL>} buildCheckoutUrl promise
*/
buildCheckoutUrl(payload) {
return new Promise((resolve, reject) => {
// Mảng các tham số chuyển tới NganLuong Payment
const data = Object.assign({}, this.checkoutPayloadDefaults, payload);
const config = this.config;
data.nganluongSecretKey = config.secureSecret;
data.nganluongMerchant = config.merchant;
data.receiverEmail = config.receiverEmail;
// Input type checking
try {
this.validateCheckoutPayload(data);
} catch (error) {
reject(error.message);
}
// Step 1: Map data to ngan luong checkout params
/* prettier-ignore */
const arrParam = {
function : data.nganluongCommand,
cur_code : data.currency ? data.currency.toLowerCase() : 'vnd',
version : data.nganluongVersion,
merchant_id : data.nganluongMerchant,
receiver_email : data.receiverEmail,
merchant_password : createMd5Hash(data.nganluongSecretKey),
order_code : data.orderId,
total_amount : String(data.amount),
payment_method : data.paymentMethod,
bank_code : data.bankCode,
payment_type : data.paymentType,
order_description : data.orderInfo,
tax_amount : data.taxAmount,
fee_shipping : data.feeShipping || '0',
discount_amount : data.discountAmount || '0',
return_url : data.returnUrl,
cancel_url : data.cancelUrl,
buyer_fullname : data.customerName,
buyer_email : data.customerEmail,
buyer_mobile : data.customerPhone,
buyer_address : data.billingStreet,
time_limit : data.timeLimit,
lang_code : data.locale,
affiliate_code : data.affiliateCode,
total_item : data.totalItem,
};
// Step 2: Post checkout data to ngan luong server
const url = config.paymentGateway;
const params = [];
Object.keys(arrParam).forEach(key => {
const value = arrParam[key];
if (value == null || value.length === 0) {
// skip empty params (but they must be optional)
return;
}
if (value.length > 0) {
params.push(`${key}=${encodeURI(value)}`);
}
});
const options = {
method: 'POST',
};
fetch(`${url}?${params.join('&')}`, options)
.then(rs => rs.text())
.then(rs => {
parseString(rs, (err, result) => {
const objectResponse = result.result || {};
if (objectResponse.error_code[0] === '00') {
resolve({
href: objectResponse.checkout_url[0],
});
} else {
reject(new Error(objectResponse.description[0]));
}
});
});
});
}
/**
* Validate checkout payload against specific schema. Throw ValidationErrors if invalid against checkoutSchema
* <br>
* _Kiểm tra tính hợp lệ của dữ liệu thanh toán dựa trên một cấu trúc dữ liệu cụ thể. Hiển thị lỗi nếu không hợp lệ với checkoutSchema._
*
* @param {NganLuongCheckoutPayload} payload
*/
validateCheckoutPayload(payload) {
NganLuong.checkoutSchema.validate(payload);
}
/**
* Return default checkout Payloads
*
* _Lấy checkout payload mặc định cho cổng thanh toán này_
* @return {NganLuongCheckoutPayload} default payload object <br> _Dữ liệu mặc định của đối tượng_
*/
get checkoutPayloadDefaults() {
/* prettier-ignore */
return {
currency : NganLuong.CURRENCY_VND,
locale : NganLuong.LOCALE_VN,
nganluongVersion : NganLuong.VERSION,
nganluongCommand : NganLuong.COMMAND,
};
}
/**
* @typedef {Object} NganLuongReturnObject
* @property {boolean} isSuccess whether the payment succeeded or not
* @property {string} message Approve or error message based on response code
* @property {string} merchant merchant ID, should be same with checkout request
* @property {string} transactionId merchant's transaction ID, should be same with checkout request
* @property {string} amount amount paid by customer
* @property {string} orderInfo order info, should be same with checkout request
* @property {string} responseCode response code, payment has errors if it is non-zero
* @property {string} bankCode bank code of the bank where payment was occurred
* @property {string} gatewayTransactionNo Gateway's own transaction ID, used to look up at Gateway's side
*
* @property {string} error_code e.g: '00'
* @property {string} token e.g: '43614-fc2a3698ee92604d5000434ed129d6a8'
* @property {string} description e.g: ''
* @property {string} transaction_status e.g: '00'
* @property {string} receiver_email e.g: 'tung.tran@naustud.io'
* @property {string} order_code e.g: 'adidas'
* @property {string} total_amount e.g: '90000'
* @property {string} payment_method e.g: 'ATM_ONLINE'
* @property {string} bank_code e.g: 'BAB'
* @property {string} payment_type e.g: '2'
* @property {string} order_description e.g: 'Test'
* @property {string} tax_amount e.g: '0'
* @property {string} discount_amount e.g: '0'
* @property {string} fee_shipping e.g: '0'
* @property {string} return_url e.g: 'http%3A%2F%2Flocalhost%3A8080%2Fpayment%2Fnganluong%2Fcallback'
* @property {string} cancel_url e.g: 'http%3A%2F%2Flocalhost%3A8080%2F'
* @property {string} buyer_fullname e.g: 'Nguyen Hue'
* @property {string} buyer_email e.g: 'tu.nguyen@naustud.io'
* @property {string} buyer_mobile e.g: '0948231723'
* @property {string} buyer_address e.g: 'TEst'
* @property {string} affiliate_code e.g: ''
* @property {string} transaction_id e.g: '19563733'
*/
/**
* Verify return query string from NganLuong using enclosed vnp_SecureHash string
*<br>
* _Hàm thực hiện xác minh tính đúng đắn của các tham số trả về từ nganluong Payment_
*
* @param {Object} query Query data object from GET handler (`response.query`) <br> _Dữ liệu được trả về từ GET handler (`response.query`)_
* @return {Promise<NganLuongReturnObject>} Promise object which resolved with normalized returned data object, with additional fields like isSuccess. <br> _Promise khi hoàn thành sẽ trả về object data từ cổng thanh toán, được chuẩn hóa tên theo camelCase và đính kèm thuộc tính isSuccess_
*/
verifyReturnUrl(query) {
return new Promise(resolve => {
const data = {};
const config = this.config;
const token = query.token || query.token_nl;
if (!token) {
resolve({
isSuccess: false,
message: 'No token found',
});
}
data.nganluongSecretKey = config.secureSecret;
data.nganluongMerchant = config.merchant;
data.receiverEmail = config.receiverEmail;
// Step 1: Map data to ngan luong get detail params
/* prettier-ignore */
const arrParam = {
merchant_id : data.nganluongMerchant,
merchant_password : createMd5Hash(data.nganluongSecretKey),
version : data.nganluongVersion,
function : 'GetTransactionDetail',
token,
};
// Step 2: Post checkout data to ngan luong server
const url = config.paymentGateway;
const params = [];
Object.keys(arrParam).forEach(key => {
const value = arrParam[key];
if (value == null || value.length === 0) {
// skip empty params (but they must be optional)
return;
}
if (value.length > 0) {
params.push(`${key}=${encodeURIComponent(value)}`);
}
});
const options = {
method: 'POST',
};
fetch(`${url}?${params.join('&')}`, options)
.then(rs => rs.text())
.then(rs => {
parseString(rs, (err, result) => {
const objectResponse = result.result || {};
if (objectResponse.error_code[0] === '00') {
objectResponse.merchant = data.nganluongMerchant;
const returnObject = this._mapQueryToObject(objectResponse);
resolve(Object.assign({}, returnObject, { isSuccess: true }));
} else {
resolve({
isSuccess: false,
message: objectResponse.description || NganLuong.getReturnUrlStatus(objectResponse.error_code[0]),
});
}
});
});
});
}
_mapQueryToObject(query) {
const returnObject = {};
Object.keys(query).forEach(key => {
returnObject[key] = query[key][0];
});
return Object.assign({}, returnObject, {
merchant: returnObject.merchant,
transactionId: returnObject.order_code,
amount: returnObject.total_amount,
orderInfo: returnObject.order_description,
responseCode: returnObject.transaction_status,
bankCode: returnObject.bank_code,
gatewayTransactionNo: returnObject.transaction_id,
message: returnObject.description || NganLuong.getReturnUrlStatus(returnObject.error_code),
customerEmail: returnObject.buyer_email,
customerPhone: returnObject.buyer_mobile,
customerName: returnObject.buyer_fullname,
});
}
/**
* Get known response code status
* <br>
* _Lấy chuỗi trạng thái từ response code đã biết_
* @param {string} responseCode Response code from gateway <br> _Mã trả về từ cổng thanh toán_
* @param {string} locale Same locale at the buildCheckoutUrl. Note, 'vn' for Vietnamese <br> _Cùng nơi với hàm buildCheckoutUrl. Lưu ý, 'vn' là Việt Nam_
* @return {string} A string contains error status converted from response code <br> _Một chuỗi chứa trạng thái lỗi được chuyển lại từ response code_
*/
static getReturnUrlStatus(responseCode, locale = 'vn') {
const responseCodeTable = {
'00': {
vn: 'Giao dịch thành công',
en: 'Approved',
},
'02': {
vn: 'Địa chỉ IP của merchant gọi tới NganLuong.vn không được chấp nhận',
en: 'Invalid IP Address',
},
'03': {
vn: 'Sai tham số gửi tới NganLuong.vn (có tham số sai tên hoặc kiểu dữ liệu)',
en: 'Sent data is not in the right format',
},
'04': {
vn: 'Tên hàm API do merchant gọi tới không hợp lệ (không tồn tại)',
en: 'API function name not found',
},
'05': {
vn: 'Sai version của API',
en: 'Wrong API version',
},
'06': {
vn: 'Mã merchant không tồn tại hoặc chưa được kích hoạt',
en: 'Merchant code not found or not activated yet',
},
'07': {
vn: 'Sai mật khẩu của merchant',
en: 'Wrong merchant password',
},
'08': {
vn: 'Tài khoản người bán hàng không tồn tại',
en: 'Seller account not found',
},
'09': {
vn: 'Tài khoản người nhận tiền đang bị phong tỏa',
en: 'Receiver account is frozen',
},
10: {
vn: 'Hóa đơn thanh toán không hợp lệ',
en: 'Invalid payment bill',
},
11: {
vn: 'Số tiền thanh toán không hợp lệ',
en: 'Invalid amount',
},
12: {
vn: 'Đơn vị tiền tệ không hợp lệ',
en: 'Invalid money currency',
},
29: {
vn: 'Token không tồn tại',
en: 'Token not found',
},
80: {
vn: 'Không thêm được đơn hàng',
en: "Can't add more order",
},
81: {
vn: 'Đơn hàng chưa được thanh toán',
en: 'The order has not yet been paid',
},
110: {
vn: 'Địa chỉ email tài khoản nhận tiền không phải email chính',
en: 'The email address is not the primary email',
},
111: {
vn: 'Tài khoản nhận tiền đang bị khóa',
en: 'Receiver account is locked',
},
113: {
vn: 'Tài khoản nhận tiền chưa cấu hình là người bán nội dung số',
en: 'Receiver account is not configured as digital content sellers',
},
114: {
vn: 'Giao dịch đang thực hiện, chưa kết thúc',
en: 'Pending transaction',
},
115: {
vn: 'Giao dịch bị hủy',
en: 'Cancelled transaction',
},
118: {
vn: 'tax_amount không hợp lệ',
en: 'Invalid tax_amount',
},
119: {
vn: 'discount_amount không hợp lệ',
en: 'Invalid discount_amount',
},
120: {
vn: 'fee_shipping không hợp lệ',
en: 'Invalid fee_shipping',
},
121: {
vn: 'return_url không hợp lệ',
en: 'Invalid return_url',
},
122: {
vn: 'cancel_url không hợp lệ',
en: 'Invalid cancel_url',
},
123: {
vn: 'items không hợp lệ',
en: 'Invalid items',
},
124: {
vn: 'transaction_info không hợp lệ',
en: 'Invalid transaction_info',
},
125: {
vn: 'quantity không hợp lệ',
en: 'Invalid quantity',
},
126: {
vn: 'order_description không hợp lệ',
en: 'Invalid order_description',
},
127: {
vn: 'affiliate_code không hợp lệ',
en: 'Invalid affiliate_code',
},
128: {
vn: 'time_limit không hợp lệ',
en: 'Invalid time_limit',
},
129: {
vn: 'buyer_fullname không hợp lệ',
en: 'Invalid buyer_fullname',
},
130: {
vn: 'buyer_email không hợp lệ',
en: 'Invalid buyer_email',
},
131: {
vn: 'buyer_mobile không hợp lệ',
en: 'Invalid buyer_mobile',
},
132: {
vn: 'buyer_address không hợp lệ',
en: 'Invalid buyer_address',
},
133: {
vn: 'total_item không hợp lệ',
en: 'Invalid total_item',
},
134: {
vn: 'payment_method, bank_code không hợp lệ',
en: 'Invalid payment_method, bank_code',
},
135: {
vn: 'Lỗi kết nối tới hệ thống ngân hàng',
en: 'Error connecting to banking system',
},
140: {
vn: 'Đơn hàng không hỗ trợ thanh toán trả góp',
en: 'The order does not support installment payments',
},
99: {
vn: 'Lỗi không được định nghĩa hoặc không rõ nguyên nhân',
en: 'Unknown error',
},
default: {
vn: 'Giao dịch thất bại',
en: 'Failured',
},
};
const respondText = responseCodeTable[responseCode];
return respondText ? respondText[locale] : responseCodeTable.default[locale];
}
}
/**
* @typedef {Object} NganLuongCheckoutPayload
* @property {string} createdDate optional: true
* @property {number} amount The payment mount
* @property {string} clientIp optional: true, max: 16
* @property {string} currency allowedValues: ['vnd', 'VND', 'USD', 'usd']
* @property {string} billingCity optional: true, max: 255
* @property {string} billingCountry optional: true, max: 255
* @property {string} billingPostCode optional: true, max: 255
* @property {string} billingStateProvince optional: true, max: 255
* @property {string} billingStreet optional: true, max: 255
* @property {string} customerId optional: true, max: 255
* @property {string} deliveryAddress optional: true, max: 255
* @property {string} deliveryCity optional: true, max: 255
* @property {string} deliveryCountry optional: true, max: 255
* @property {string} deliveryProvince optional: true, max: 255
* @property {string} locale allowedValues: ['vi', 'en']
* @property {string} orderId max: 34
* @property {string} receiverEmail max: 255, regEx: SimpleSchema.RegEx.Email
* @property {string} paymentMethod allowedValues: ['NL', 'VISA', 'MASTER', 'JCB', 'ATM_ONLINE', 'ATM_OFFLINE', 'NH_OFFLINE', 'TTVP', 'CREDIT_CARD_PREPAID', 'IB_ONLINE']
* @property {string} bankCode optional: true, max: 50 (required with ATM_ONLINE, ATM_OFFLINE, NH_OFFLINE, CREDIT_CARD_PREPAID)
* @property {string} paymentType optional: true, allowedValues: ['1', '2']
* @property {string} orderInfo optional: true, max: 500
* @property {number} taxAmount Integer, optional: true
* @property {number} discountAmount Integer, optional: true
* @property {number} feeShipping Integer, optional: true
* @property {string} customerEmail max: 255, regEx: SimpleSchema.RegEx.Email
* @property {string} customerPhone max: 255
* @property {string} customerName max: 255
* @property {string} returnUrl max: 255
* @property {string} cancelUrl max: 255, optional: true
* @property {number} timeLimit Integer, optional: true; minu
* @property {string} affiliateCode max: 255, optional: true
* @property {string} totalItem optional: true
* @property {string} transactionId max: 40
* @property {string} nganluongSecretKey max: 32
* @property {string} nganluongMerchant max: 16
* @property {string} nganluongCommand max: 32
* @property {string} nganluongVersion max: 3
* @property {string} paymentGateway regEx: SimpleSchema.RegEx.Url
* @property {string} merchant
* @property {string} receiverEmail
* @property {string} secureSecret
*/
/* prettier-ignore */
/**
* The schema is based on field data requirements from NganLuong's dev document
* <br>
* _Cấu trúc dữ liệu được dựa trên các yêu cầu của tài liệu Ngân Lượng_
* @type {SimpleSchema}
*/
NganLuong.checkoutSchema = new SimpleSchema({
createdDate : { type: String, optional: true },
amount : { type: SimpleSchema.Integer },
clientIp : { type: String, optional: true, max: 16 },
currency : { type: String, allowedValues: ['vnd', 'VND', 'USD', 'usd'] },
billingCity : { type: String, optional: true, max: 255 }, // NOTE: no max limit documented for optional fields, this is just a safe value
billingCountry : { type: String, optional: true, max: 255 },
billingPostCode : { type: String, optional: true, max: 255 },
billingStateProvince : { type: String, optional: true, max: 255 },
billingStreet : { type: String, optional: true, max: 255 },
customerId : { type: String, optional: true, max: 255 },
deliveryAddress : { type: String, optional: true, max: 255 },
deliveryCity : { type: String, optional: true, max: 255 },
deliveryCountry : { type: String, optional: true, max: 255 },
deliveryProvince : { type: String, optional: true, max: 255 },
locale : { type: String, allowedValues: ['vi', 'en'] },
orderId : { type: String, max: 34 },
receiverEmail : { type: String, max: 255, regEx: SimpleSchema.RegEx.Email },
paymentMethod : { type: String, allowedValues: ['NL', 'VISA', 'MASTER', 'JCB', 'ATM_ONLINE', 'ATM_OFFLINE', 'NH_OFFLINE', 'TTVP', 'CREDIT_CARD_PREPAID', 'IB_ONLINE'] },
bankCode : { type: String, optional: true, max: 50 },
paymentType : { type: String, optional: true, allowedValues: ['1', '2'] },
orderInfo : { type: String, optional: true, max: 500 },
taxAmount : { type: SimpleSchema.Integer, optional: true },
discountAmount : { type: SimpleSchema.Integer, optional: true },
feeShipping : { type: SimpleSchema.Integer, optional: true },
customerEmail : { type: String, max: 255, regEx: SimpleSchema.RegEx.Email },
customerPhone : { type: String, max: 255 },
customerName : { type: String, max: 255 },
returnUrl : { type: String, max: 255 },
cancelUrl : { type: String, max: 255, optional: true },
timeLimit : { type: SimpleSchema.Integer, optional: true }, // minutes
affiliateCode : { type: String, max: 255, optional: true },
totalItem : { type: String, optional: true },
transactionId : { type: String, max: 40 },
nganluongSecretKey : { type: String, max: 32 },
nganluongMerchant : { type: String, max: 16 },
nganluongCommand : { type: String, max: 32 },
nganluongVersion : { type: String, max: 3 },
});
NganLuong.configSchema = new SimpleSchema({
paymentGateway: { type: String, regEx: SimpleSchema.RegEx.Url },
merchant: { type: String },
receiverEmail: { type: String },
secureSecret: { type: String },
});
// should not be changed
NganLuong.VERSION = '3.1';
NganLuong.COMMAND = 'SetExpressCheckout';
// nganluong only support VND
NganLuong.CURRENCY_VND = 'vnd';
NganLuong.LOCALE_EN = 'en';
NganLuong.LOCALE_VN = 'vi';
/**
* NganLuong test configs
* <br>
* _Cấu hình dùng thử Ngân Lượng_
*/
NganLuong.TEST_CONFIG = {
paymentGateway: 'https://sandbox.nganluong.vn:8088/nl35/checkout.api.nganluong.post.php',
merchant: '45571',
receiverEmail: 'tung.tran@naustud.io',
secureSecret: 'c57700e78cb0df1766279d91e3233c79',
};
export { NganLuong };