593 lines
20 KiB
JavaScript
593 lines
20 KiB
JavaScript
/**
|
|
* Functions to interact with Worldpay
|
|
* This is based on the Worldpay JSON API Specification.
|
|
* @see {@url https://developer.worldpay.com/jsonapi/api}
|
|
*/
|
|
'use strict';
|
|
|
|
const Q = require('q');
|
|
const _ = require('lodash');
|
|
const debug = require('debug')('utils:acquirers:worldpay');
|
|
|
|
const utils = require(global.pathPrefix + 'utils.js');
|
|
const errors = require(global.pathPrefix + '../utils/acquirers/acquirer_errors.js');
|
|
const worldpay = require(global.pathPrefix + 'worldpay.js');
|
|
const encryption = require(global.pathPrefix + '../utils/encryption.js');
|
|
const formatting = require(global.pathPrefix + '../utils/formatting.js');
|
|
const config = require(global.configFile);
|
|
|
|
module.exports = {
|
|
payTransaction,
|
|
validateMerchantAccount,
|
|
invalidateMerchantAccount,
|
|
tokeniseCard
|
|
};
|
|
|
|
/**
|
|
* Worldpay doesn't allow invalidating merchant account access keys from the API,
|
|
* so we just always return success. The user would have to manualy invalidate
|
|
* their tokens
|
|
*
|
|
* @returns {promise} - A promise that resolves on success, or rejects on fail
|
|
*/
|
|
function invalidateMerchantAccount() {
|
|
return Q.resolve();
|
|
}
|
|
|
|
/**
|
|
* Makes a payment from customer to merchant using the appropriate acquirer
|
|
*
|
|
* @param {Object} client - the client making the payment
|
|
* @param {Object} device - the device making the payment
|
|
* @param {Object} data - various neccessary data
|
|
* @param {string} data.ClientKey - the client key required to decrypt the payment details
|
|
* @param {string} data.ipAddress - ipAddress of the client
|
|
* @param {Object} [data.cardDetails] - optional decrypted card details
|
|
* @param {Object} transaction - The transaction object with the payment info
|
|
* @param {Object} merchantInfo - Merchant account and address info
|
|
* @param {Object} customerInfo - Customer account and address info
|
|
*
|
|
* @returns {Promise} - Resolves to payment info, or rejects ERRORS value
|
|
*/
|
|
function payTransaction(
|
|
client,
|
|
device,
|
|
data,
|
|
transaction,
|
|
merchantInfo,
|
|
customerInfo
|
|
) {
|
|
/**
|
|
* Check that we have a payment method we can use
|
|
*/
|
|
if (
|
|
(customerInfo.account.AccountType !== 'Credit/Debit Payment Card' &&
|
|
customerInfo.account.AccountType !== 'Direct Credit/Debit Card Payment') ||
|
|
customerInfo.account.AcquirerName === 'Demo'
|
|
) {
|
|
return Q.reject({name: errors.INVALID_COMBINATION});
|
|
}
|
|
|
|
//
|
|
// Decryt the service key for the merchant
|
|
//
|
|
let merchantAccountDetails;
|
|
try {
|
|
merchantAccountDetails = encryption.decryptWorldpayMerchant(merchantInfo.account);
|
|
} catch (error) {
|
|
return Q.reject({name: errors.INVALID_MERCHANT_ACCOUNT_DETAILS});
|
|
}
|
|
|
|
//
|
|
// Get the details for the request
|
|
//
|
|
const path = 'orders';
|
|
const getBodyP = getPayTransactionRequestBody(client, device, data, transaction, customerInfo);
|
|
|
|
//
|
|
// Make the request
|
|
//
|
|
const requestP = getBodyP.then((body) => {
|
|
return Q.nfcall(
|
|
worldpay.worldpayFunction,
|
|
'POST',
|
|
path,
|
|
merchantAccountDetails.worldpayServiceKey,
|
|
null, // No additional headers
|
|
body
|
|
).catch((error) => {
|
|
//
|
|
// Convert errors here. Success is handled below
|
|
// The error may have more details.
|
|
//
|
|
debug('orders error:', error);
|
|
const errorCode = worldpayErrorToErrorCode(error);
|
|
return Q.reject({
|
|
name: errorCode,
|
|
info: error.message
|
|
});
|
|
});
|
|
});
|
|
|
|
//
|
|
// Check everything worked
|
|
//
|
|
return Q.all([getBodyP, requestP]).spread((requestBody, response) => {
|
|
if (response.paymentStatus !== 'SUCCESS') {
|
|
return Q.reject({name: errors.PAYMENT_FAILED_UNSPECIFIED});
|
|
}
|
|
|
|
//
|
|
// Succeeded, so return the information we want to keep
|
|
//
|
|
const info = {
|
|
SaleReference: response.orderCode,
|
|
SaleAuthCode: response.customerOrderCode,
|
|
RiskScore: response.riskScore.value,
|
|
AVSResponse: '',
|
|
GatewayResponse: response.paymentStatus
|
|
};
|
|
return Q.resolve(info);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validates that a merchant account is valid for Worldpay. There is no specific
|
|
* function in the Worldpay API to check, so instead we make a trivial GET
|
|
* request and look for 401 error if the account key is wrong.
|
|
*
|
|
* @param {Object} account - the account to validate
|
|
* @returns {Promise} - resolves on succes or rejects with ERRORS value
|
|
*/
|
|
function validateMerchantAccount(account) {
|
|
//
|
|
// Decryt the service key for the merchant
|
|
//
|
|
const merchantAccountDetails = encryption.decryptWorldpayMerchant(account);
|
|
if (!merchantAccountDetails) {
|
|
return Q.reject({name: errors.INVALID_MERCHANT_ACCOUNT_DETAILS});
|
|
}
|
|
|
|
//
|
|
// We do a simple request for a non-existant order, and see what error code
|
|
// we get back. We are looking for unauthorised versus various "authorised
|
|
// but failed" errors.
|
|
//
|
|
return Q.nfcall(
|
|
worldpay.worldpayFunction,
|
|
'GET',
|
|
'/orders/00000000-0000-0000-0000-000000000000', // Request a non-existent order
|
|
merchantAccountDetails.worldpayServiceKey,
|
|
null, // No additional headers
|
|
{} // No body
|
|
)
|
|
.then(() => Q.resolve()) // Very surprising if we get here, but is still ok
|
|
.catch((error) => {
|
|
//
|
|
// Convert errors here. Success is handled below
|
|
// The error may have more details.
|
|
//
|
|
debug('validate accounts error:', error);
|
|
if (error.hasOwnProperty('customCode')) {
|
|
//
|
|
// Some error codes are expected and mean the token is ok
|
|
//
|
|
const pass = {
|
|
ORDER_NOT_FOUND: true,
|
|
INVALID_PAYMENT_DETAILS: true
|
|
};
|
|
if (pass[error.customCode]) {
|
|
return Q.resolve();
|
|
}
|
|
|
|
// Other errors are converted and returned
|
|
const errorCode = worldpayErrorToErrorCode(error);
|
|
return Q.reject({
|
|
name: errorCode,
|
|
info: error.message
|
|
});
|
|
} else {
|
|
// Some network type error, so report it as service being down
|
|
return Q.reject({
|
|
name: errors.ACQUIRER_DOWN,
|
|
info: error.message
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Builds the body for a Worldpay 'orders' request to pay a transaction
|
|
*
|
|
* @param {Object} client - the client making the payment
|
|
* @param {Object} device - the device the client is using
|
|
* @param {Object} data - various neccessary data
|
|
* @param {string} data.ClientKey - the client key required to decrypt the payment details
|
|
* @param {string} data.ipAddress - ipAddress of the client
|
|
* @param {Object} [data.cardDetails] - optional decrypted card details
|
|
* @param {Object} transaction - The transaction object with the payment info
|
|
* @param {Object} customerInfo - Customer account and address info
|
|
*
|
|
* @returns {Promise} - Resolves to the body for the request, or rejects ERRORS value
|
|
*/
|
|
function getPayTransactionRequestBody(client, device, data, transaction, customerInfo) {
|
|
//
|
|
// Decrypt the credit card and merchant account information
|
|
//
|
|
let cardDetails;
|
|
try {
|
|
cardDetails =
|
|
data.cardDetails ||
|
|
encryption.decryptCard(customerInfo.account, data.ClientKey, client._id.toString());
|
|
} catch (error) {
|
|
return Q.reject({name: errors.INVALID_CARD_DETAILS});
|
|
}
|
|
|
|
//
|
|
// Build the command we want to send
|
|
//
|
|
const requestBody = {
|
|
//
|
|
// Top level fields
|
|
//
|
|
orderType: 'ECOM',
|
|
currencyCode: 'GBP',
|
|
settlementCurrency: 'GBP', // In case merchant has enabled multiple currencies
|
|
amount: transaction.TotalAmount,
|
|
customerOrderCode: transaction._id.toString(),
|
|
shopperEmailAddress: client.ClientName,
|
|
|
|
orderDescription: getOrderDescription(transaction),
|
|
name: getCustomerName(client),
|
|
|
|
//
|
|
// Set the delivery address to be the same as the billing address as
|
|
// that is the most likely to be associated with the card.
|
|
//
|
|
billingAddress: getWorldpayAddress(client, customerInfo.address, false),
|
|
deliveryAddress: getWorldpayAddress(client, customerInfo.address, true),
|
|
|
|
//
|
|
// Set the payment method object
|
|
paymentMethod: getWorldpayPaymentMethod(customerInfo.account, cardDetails)
|
|
};
|
|
|
|
//
|
|
// Shopper IP and session ID are not available through the integration API
|
|
//
|
|
if (data.ipAddress) {
|
|
requestBody.shopperIpAddress = data.ipAddress;
|
|
}
|
|
if (device.SessionToken) {
|
|
requestBody.shopperSessionID = device.SessionToken;
|
|
}
|
|
return Q.resolve(requestBody);
|
|
}
|
|
|
|
/**
|
|
* Builds the order description
|
|
*
|
|
* @param {Object} transaction - The transaction object
|
|
* @returns {string} - A description string
|
|
*/
|
|
function getOrderDescription(transaction) {
|
|
let desc = 'Bridge';
|
|
if (transaction.MerchantComment !== '') {
|
|
desc += ': ' + transaction.MerchantComment;
|
|
}
|
|
return desc;
|
|
}
|
|
|
|
/**
|
|
* Builds a customer's full name from the various parts of their name that we store
|
|
*
|
|
* @param {Object} client - the client object
|
|
* @returns {string} - the client's name to report to WorldPay
|
|
*/
|
|
function getCustomerName(client) {
|
|
const kyc = client.KYC[0];
|
|
const parts = [
|
|
kyc.Title,
|
|
kyc.FirstName,
|
|
kyc.MiddleNames,
|
|
kyc.LastName
|
|
];
|
|
|
|
// Remove any parts that are undefined, then join the parts into a name
|
|
return _.compact(parts).join(' ');
|
|
}
|
|
|
|
/**
|
|
* Format our addresses into the format required by Worldpay
|
|
*
|
|
* @param {Object} client - the client object (for first and last name)
|
|
* @param {Object} address - the address object to convert
|
|
* @param {boolean} includeName - true to include the name (e.g. delivery address)
|
|
* @returns {Object} - Worldpay format address object
|
|
*/
|
|
function getWorldpayAddress(client, address, includeName) {
|
|
//
|
|
// Most parts are hardcoded conversions
|
|
//
|
|
const wpAddress = {
|
|
postalCode: address.PostCode,
|
|
city: address.Town,
|
|
state: address.County,
|
|
countryCode: 'GB',
|
|
telephoneNumber: address.PhoneNumber
|
|
};
|
|
|
|
//
|
|
// Include name if requested
|
|
//
|
|
if (includeName) {
|
|
wpAddress.firstName = client.KYC[0].FirstName;
|
|
wpAddress.lastName = client.KYC[0].LastName;
|
|
}
|
|
|
|
//
|
|
// Street addresses are variable length, and we have an optional building
|
|
// name / flat number. So make sure we put the right parts in the right
|
|
// place depending on what fields we have.
|
|
//
|
|
const parts = [
|
|
address.BuildingNameFlat,
|
|
address.Address1,
|
|
address.Address2
|
|
];
|
|
const setParts = _.compact(parts);
|
|
for (let i = 1; i <= setParts.length; ++i) {
|
|
wpAddress['address' + i] = setParts[i];
|
|
}
|
|
|
|
return wpAddress;
|
|
}
|
|
|
|
/**
|
|
* Gets a Worldpay formatted payment method based on account and decrypted card details
|
|
*
|
|
* @param {Object} account - The account to pay from
|
|
* @param {Object} decryptedCardDetails - The decrypted card details from the account
|
|
*
|
|
* @returns {Object} - Worldpay formatted payment details
|
|
*/
|
|
function getWorldpayPaymentMethod(account, decryptedCardDetails) {
|
|
const paymentMethod = {
|
|
type: 'Card',
|
|
name: account.NameOnAccount,
|
|
expiryMonth: decryptedCardDetails.expiryMonth,
|
|
expiryYear: decryptedCardDetails.expiryYear,
|
|
cardNumber: decryptedCardDetails.cardNumber
|
|
|
|
// start date an issue number are optional, Added below if they exist.
|
|
};
|
|
|
|
if (decryptedCardDetails.startMonth && decryptedCardDetails.startYear) {
|
|
paymentMethod.startYear = decryptedCardDetails.startYear;
|
|
paymentMethod.startMonth = decryptedCardDetails.startMonth;
|
|
}
|
|
|
|
if (decryptedCardDetails.issueNumber) {
|
|
paymentMethod.issueNumber = decryptedCardDetails.issueNumber;
|
|
}
|
|
|
|
return paymentMethod;
|
|
}
|
|
|
|
/**
|
|
* Tokenises the card and returns some interesting information about it, along
|
|
* with the encrypted token (assuming the appropriate keys are provided).
|
|
*
|
|
* @param {Object} cardDetails - the card details to tokenise
|
|
* @param {string?} clientKey - the client Key (to encrypt the token)
|
|
* @param {string?} clientID - the client ID (to encrypt the token)
|
|
* @returns {Promise} - a promise for the successful tokenisation
|
|
*/
|
|
function tokeniseCard(cardDetails, clientKey, clientID) {
|
|
//
|
|
// Get the details for the request
|
|
//
|
|
const path = 'tokens';
|
|
const body = getTokeniseCardRequestBody(cardDetails);
|
|
|
|
return Q.nfcall(
|
|
worldpay.worldpayFunction,
|
|
'POST',
|
|
path,
|
|
null, // No service key
|
|
null, // No additional headers
|
|
body
|
|
).then(
|
|
(response) => {
|
|
return tokenisedResponseToCardDetails(response, clientKey, clientID);
|
|
},
|
|
(err) => {
|
|
//
|
|
// If there was a communication error, convert it to the appropriate
|
|
// acquirers error code.
|
|
//
|
|
// Note that this is in the form where the error callback is the
|
|
// second parameter to then(), rather than the more common
|
|
// .then().catch() approach as we want to only handle errors from
|
|
// worlpayFunction, not any errors from tokenisedResponseToCardDetails()
|
|
// which are already correctly formatted and returned as promise rejections.
|
|
//
|
|
debug('tokenise card error:', err);
|
|
|
|
const errorCode = worldpayErrorToErrorCode(err);
|
|
return Q.reject({
|
|
name: errorCode,
|
|
info: err.message
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets the properly formatted body for sending to worldpay
|
|
*
|
|
* @param {Object} cardDetails - card details in the format of an AddCard request
|
|
* @returns {Object} - the body for the request
|
|
*/
|
|
function getTokeniseCardRequestBody(cardDetails) {
|
|
//
|
|
// Split up the card dates we (may) have
|
|
//
|
|
const startDate = formatting.splitCardDate(cardDetails.CardValidFrom);
|
|
const expiryDate = formatting.splitCardDate(cardDetails.CardExpiry);
|
|
|
|
//
|
|
// Initialise the body with the required params
|
|
//
|
|
const body = {
|
|
reusable: true,
|
|
paymentMethod: {
|
|
name: cardDetails.NameOnAccount,
|
|
expiryMonth: expiryDate.month,
|
|
expiryYear: expiryDate.year,
|
|
cardNumber: cardDetails.CardPAN,
|
|
type: 'Card',
|
|
cvc: cardDetails.CVV
|
|
},
|
|
clientKey: config.worldpayClientKey // Always use the Comcarde ClientKey
|
|
};
|
|
|
|
//
|
|
// Add the optional params
|
|
//
|
|
if (startDate) {
|
|
body.paymentMethod.startMonth = startDate.month;
|
|
body.paymentMethod.startYear = startDate.year;
|
|
}
|
|
|
|
if (cardDetails.IssueNumber) {
|
|
body.paymentMethod.issueNumber = cardDetails.IssueNumber;
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* Convert the Worldpay response into the standard format for the database
|
|
* including the further details of the card.
|
|
*
|
|
* @param {Object} response - the worldpay response
|
|
* @param {string?} clientKey - the client Key (to encrypt the token)
|
|
* @param {string?} clientID - the client ID (to encrypt the token)
|
|
* @returns {Promise} - Promise for the formatted information, or rejects on error
|
|
*/
|
|
function tokenisedResponseToCardDetails(response, clientKey, clientID) {
|
|
//
|
|
// Encrypt the token or leave it blank if we don't have the keys
|
|
//
|
|
let encryptedToken = '';
|
|
if (clientKey && clientID) {
|
|
encryptedToken = utils.encryptDataV3(response.token, clientKey, clientID);
|
|
}
|
|
if (_.isObject(encryptedToken)) {
|
|
//
|
|
// Some unexpected error when encrypting the token.
|
|
//
|
|
return Q.reject({
|
|
name: errors.TOKEN_ENCRYPTION_FAILED,
|
|
info: String(encryptedToken.code) + ': ' + encryptedToken.message
|
|
});
|
|
}
|
|
|
|
const encryptedAcquirerMerchantID = utils.encryptDataV1(config.worldpayMerchantID);
|
|
if (_.isObject(encryptedAcquirerMerchantID)) {
|
|
//
|
|
// Some unexpected error when encrypting the token.
|
|
//
|
|
return Q.reject({
|
|
name: errors.TOKEN_ENCRYPTION_FAILED,
|
|
info: String(encryptedAcquirerMerchantID.code) + ': ' + encryptedAcquirerMerchantID.message
|
|
});
|
|
}
|
|
|
|
const encryptedAcquirerCipher = utils.encryptDataV1(config.worldpayServiceKey);
|
|
if (_.isObject(encryptedAcquirerCipher)) {
|
|
//
|
|
// Some unexpected error when encrypting the token.
|
|
//
|
|
return Q.reject({
|
|
name: errors.TOKEN_ENCRYPTION_FAILED,
|
|
info: String(encryptedAcquirerCipher.code) + ': ' + encryptedAcquirerCipher.message
|
|
});
|
|
}
|
|
|
|
const details = {
|
|
Token: encryptedToken || '',
|
|
AcquirerName: 'Worldpay',
|
|
AcquirerMerchantID: encryptedAcquirerMerchantID,
|
|
AcquirerCipher: encryptedAcquirerCipher,
|
|
VendorID: response.paymentMethod.cardIssuer,
|
|
VendorAccountName: response.paymentMethod.cardProductTypeDescNonContactless,
|
|
IconLocation: response.paymentMethod.cardType + '.png',
|
|
Details: {
|
|
IsCorporate: response.paymentMethod.cardSchemeType === 'corporate',
|
|
AccountClass: responseToAccountClass(response),
|
|
Type: utils.CardTypes[response.paymentMethod.cardType] || utils.CardTypes.UNKNOWN,
|
|
IssuerCountry: response.paymentMethod.countryCode
|
|
}
|
|
};
|
|
|
|
return Q.resolve(details);
|
|
}
|
|
|
|
/**
|
|
* Gets the appropriate utils.AccountClass value for the card class.
|
|
*
|
|
* @param {Object} response - the response from worldpay tokenisation request
|
|
* @returns {string} - The appropriate member of utils.AccountClass
|
|
*/
|
|
function responseToAccountClass(response) {
|
|
switch (response.paymentMethod.cardClass) {
|
|
case 'credit':
|
|
return utils.AccountClass.CREDIT;
|
|
case 'debit':
|
|
return utils.AccountClass.DEBIT;
|
|
default:
|
|
// Special case for Maestro which are marked as "unknown" but should be debit
|
|
if (response.paymentMethod.cardType === 'MAESTRO') {
|
|
return utils.AccountClass.DEBIT;
|
|
} else {
|
|
return utils.AccountClass.UNKNOWN;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts a worldpay error to one ouf our standard error codes from acquirer_errors.js
|
|
*
|
|
* @param {Object} error - the Worldpay error object
|
|
* @returns {string} - standard error string
|
|
*/
|
|
function worldpayErrorToErrorCode(error) {
|
|
if (error.hasOwnProperty('customCode')) {
|
|
// Other errors are converted and returned
|
|
const convert = {
|
|
//
|
|
// Validation errors
|
|
//
|
|
UNAUTHORIZED: errors.ACQUIRER_UNAUTHORIZED,
|
|
MERCHANT_DISABLED: errors.ACQUIRER_MERCHANT_DISABLED,
|
|
|
|
//
|
|
// Other errors
|
|
//
|
|
BAD_REQUEST: errors.ACQUIRER_BAD_REQUEST,
|
|
TKN_EXPIRED: errors.ACQUIRER_TKN_EXPIRED,
|
|
ERROR_PARSING_JSON: errors.ACQUIRER_BAD_REQUEST,
|
|
MEDIA_TYPE_NOT_SUPPORTED: errors.ACQUIRER_BAD_REQUEST,
|
|
INTERNAL_SERVER_ERROR: errors.ACQUIRER_INTERNAL_SERVER_ERROR,
|
|
UNEXPECTED_ERROR: errors.ACQUIRER_INTERNAL_SERVER_ERROR,
|
|
API_ERROR: errors.ACQUIRER_INTERNAL_SERVER_ERROR,
|
|
INVALID_PAYMENT_DETAILS: errors.ACQUIRER_INVALID_PAYMENT_DETAILS
|
|
};
|
|
return convert[error.customCode] || errors.ACQUIRER_UNKNOWN_ERROR;
|
|
} else {
|
|
// Some network type error, so report it as service being down
|
|
return errors.ACQUIRER_DOWN;
|
|
}
|
|
}
|