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