Martin Donnelly 57bd6c8e6a init
2018-06-24 21:15:03 +01:00

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;
}
}