diff --git a/imports/plugins/core/layout/client/templates/layout/alerts/reactionAlerts.js b/imports/plugins/core/layout/client/templates/layout/alerts/reactionAlerts.js index 01086b0c1f1..2cfff5b03cd 100644 --- a/imports/plugins/core/layout/client/templates/layout/alerts/reactionAlerts.js +++ b/imports/plugins/core/layout/client/templates/layout/alerts/reactionAlerts.js @@ -83,7 +83,9 @@ Object.assign(Alerts, { ...options }).then((isConfirm) => { if (isConfirm === true) { - callback(isConfirm); + if (callback) { + callback(isConfirm); + } } }); }, diff --git a/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js b/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js index 125a53f981e..9d85d3d544e 100644 --- a/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js +++ b/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js @@ -321,7 +321,8 @@ Template.coreOrderShippingInvoice.helpers({ _.each(refunds, function (item) { refundTotal += parseFloat(item.amount); }); - return paymentMethod.amount - refundTotal; + + return Math.abs(paymentMethod.amount - refundTotal); }, refundSubmitDisabled() { diff --git a/imports/plugins/included/braintree/client/checkout/braintree.js b/imports/plugins/included/braintree/client/checkout/braintree.js index f5347378592..cf9e8ddde24 100644 --- a/imports/plugins/included/braintree/client/checkout/braintree.js +++ b/imports/plugins/included/braintree/client/checkout/braintree.js @@ -34,7 +34,7 @@ handleBraintreeSubmitError = function (error) { if (serverError) { return paymentAlert("Server Error " + serverError); } else if (error) { - return paymentAlert("Oops " + error); + return paymentAlert("Oops! Credit card is invalid. Please check your information and try again."); } }; diff --git a/imports/plugins/included/braintree/lib/collections/schemas/braintree.js b/imports/plugins/included/braintree/lib/collections/schemas/braintree.js index e97296d74dc..bd533270d71 100644 --- a/imports/plugins/included/braintree/lib/collections/schemas/braintree.js +++ b/imports/plugins/included/braintree/lib/collections/schemas/braintree.js @@ -43,7 +43,8 @@ export const BraintreePayment = new SimpleSchema({ }, cardNumber: { type: String, - min: 16, + min: 12, + max: 19, label: "Card number" }, expireMonth: { diff --git a/imports/plugins/included/braintree/server/methods/braintree.js b/imports/plugins/included/braintree/server/methods/braintree.js index 6cf0a442f9e..0392994e55b 100644 --- a/imports/plugins/included/braintree/server/methods/braintree.js +++ b/imports/plugins/included/braintree/server/methods/braintree.js @@ -1,243 +1,9 @@ -import moment from "moment"; +import * as BraintreeMethods from "./braintreeMethods"; import { Meteor } from "meteor/meteor"; -import Future from "fibers/future"; -import Braintree from "braintree"; -import { Reaction, Logger } from "/server/api"; -import { Packages } from "/lib/collections"; -import { PaymentMethod } from "/lib/collections/schemas"; - -function getSettings(settings, ref, valueName) { - if (settings !== null) { - return settings[valueName]; - } else if (ref !== null) { - return ref[valueName]; - } - return undefined; -} - - -function getAccountOptions() { - let environment; - let settings = Packages.findOne({ - name: "reaction-braintree", - shopId: Reaction.getShopId(), - enabled: true - }).settings; - if (typeof settings !== "undefined" && settings !== null ? settings.mode : undefined === true) { - environment = "production"; - } else { - environment = "sandbox"; - } - - let ref = Meteor.settings.braintree; - let options = { - environment: environment, - merchantId: getSettings(settings, ref, "merchant_id"), - publicKey: getSettings(settings, ref, "public_key"), - privateKey: getSettings(settings, ref, "private_key") - }; - if (!options.merchantId) { - throw new Meteor.Error("invalid-credentials", "Invalid Braintree Credentials"); - } - return options; -} - - -function getGateway() { - let accountOptions = getAccountOptions(); - if (accountOptions.environment === "production") { - accountOptions.environment = Braintree.Environment.Production; - } else { - accountOptions.environment = Braintree.Environment.Sandbox; - } - let gateway = Braintree.connect(accountOptions); - return gateway; -} - -function getPaymentObj() { - return { - amount: "", - options: {submitForSettlement: true} - }; -} - -function parseCardData(data) { - return { - cardholderName: data.name, - number: data.number, - expirationMonth: data.expirationMonth, - expirationYear: data.expirationYear, - cvv: data.cvv - }; -} Meteor.methods({ - /** - * braintreeSubmit - * Authorize, or authorize and capture payments from Brinatree - * https://developers.braintreepayments.com/reference/request/transaction/sale/node - * @param {String} transactionType - either authorize or capture - * @param {Object} cardData - Object containing everything about the Credit card to be submitted - * @param {Object} paymentData - Object containing everything about the transaction to be settled - * @return {Object} results - Object containing the results of the transaction - */ - "braintreeSubmit": function (transactionType, cardData, paymentData) { - check(transactionType, String); - check(cardData, { - name: String, - number: String, - expirationMonth: String, - expirationYear: String, - cvv2: String, - type: String - }); - check(paymentData, { - total: String, - currency: String - }); - let gateway = getGateway(); - let paymentObj = getPaymentObj(); - if (transactionType === "authorize") { - paymentObj.options.submitForSettlement = false; - } - paymentObj.creditCard = parseCardData(cardData); - paymentObj.amount = paymentData.total; - let fut = new Future(); - gateway.transaction.sale(paymentObj, Meteor.bindEnvironment(function (error, result) { - if (error) { - fut.return({ - saved: false, - error: error - }); - } else if (!result.success) { - fut.return({ - saved: false, - response: result - }); - } else { - fut.return({ - saved: true, - response: result - }); - } - }, function (error) { - Reaction.Events.warn(error); - })); - return fut.wait(); - }, - - - /** - * braintree/payment/capture - * Capture payments from Braintree - * https://developers.braintreepayments.com/reference/request/transaction/submit-for-settlement/node - * @param {Object} paymentMethod - Object containing everything about the transaction to be settled - * @return {Object} results - Object containing the results of the transaction - */ - "braintree/payment/capture": function (paymentMethod) { - check(paymentMethod, PaymentMethod); - let transactionId = paymentMethod.transactions[0].transaction.id; - let amount = paymentMethod.transactions[0].transaction.amount; - let gateway = getGateway(); - const fut = new Future(); - this.unblock(); - gateway.transaction.submitForSettlement(transactionId, amount, Meteor.bindEnvironment(function (error, result) { - if (error) { - fut.return({ - saved: false, - error: error - }); - } else { - fut.return({ - saved: true, - response: result - }); - } - }, function (e) { - Logger.warn(e); - })); - return fut.wait(); - }, - /** - * braintree/refund/create - * Refund BrainTree payment - * https://developers.braintreepayments.com/reference/request/transaction/refund/node - * @param {Object} paymentMethod - Object containing everything about the transaction to be settled - * @param {Number} amount - Amount to be refunded if not the entire amount - * @return {Object} results - Object containing the results of the transaction - */ - "braintree/refund/create": function (paymentMethod, amount) { - check(paymentMethod, PaymentMethod); - check(amount, Number); - let transactionId = paymentMethod.transactions[0].transaction.id; - let gateway = getGateway(); - const fut = new Future(); - gateway.transaction.refund(transactionId, amount, Meteor.bindEnvironment(function (error, result) { - if (error) { - fut.return({ - saved: false, - error: error - }); - } else if (!result.success) { - if (result.errors.errorCollections.transaction.validationErrors.base[0].code === "91506") { - fut.return({ - saved: false, - error: "Cannot refund transaction until it\'s settled. Please try again later" - }); - } else { - fut.return({ - saved: false, - error: result.message - }); - } - } else { - fut.return({ - saved: true, - response: result - }); - } - }, function (e) { - Logger.fatal(e); - })); - return fut.wait(); - }, - - /** - * braintree/refund/list - * List all refunds for a transaction - * https://developers.braintreepayments.com/reference/request/transaction/find/node - * @param {Object} paymentMethod - Object containing everything about the transaction to be settled - * @return {Array} results - An array of refund objects for display in admin - */ - "braintree/refund/list": function (paymentMethod) { - check(paymentMethod, Object); - let transactionId = paymentMethod.transactionId; - let gateway = getGateway(); - this.unblock(); - let braintreeFind = Meteor.wrapAsync(gateway.transaction.find, gateway.transaction); - let findResults = braintreeFind(transactionId); - let result = []; - if (findResults.refundIds.length > 0) { - for (let refund of findResults.refundIds) { - let refundDetails = getRefundDetails(refund); - result.push({ - type: "refund", - amount: parseFloat(refundDetails.amount), - created: moment(refundDetails.createdAt).unix() * 1000, - currency: refundDetails.currencyIsoCode, - raw: refundDetails - }); - } - } - return result; - } + "braintreeSubmit": BraintreeMethods.paymentSubmit, + "braintree/payment/capture": BraintreeMethods.paymentCapture, + "braintree/refund/create": BraintreeMethods.createRefund, + "braintree/refund/list": BraintreeMethods.listRefunds }); - -getRefundDetails = function (refundId) { - check(refundId, String); - let gateway = getGateway(); - let braintreeFind = Meteor.wrapAsync(gateway.transaction.find, gateway.transaction); - let findResults = braintreeFind(refundId); - return findResults; -}; - diff --git a/imports/plugins/included/braintree/server/methods/braintreeApi.js b/imports/plugins/included/braintree/server/methods/braintreeApi.js new file mode 100644 index 00000000000..fe345fad41a --- /dev/null +++ b/imports/plugins/included/braintree/server/methods/braintreeApi.js @@ -0,0 +1,222 @@ +/* eslint camelcase: 0 */ +// meteor modules +import { Meteor } from "meteor/meteor"; +// reaction modules +import { Packages } from "/lib/collections"; +import { Reaction, Logger } from "/server/api"; +import Future from "fibers/future"; +import Braintree from "braintree"; +import accounting from "accounting-js"; + +export const BraintreeApi = {}; +BraintreeApi.apiCall = {}; + + +function getPaymentObj() { + return { + amount: "", + options: {submitForSettlement: true} + }; +} + +function parseCardData(data) { + return { + cardholderName: data.name, + number: data.number, + expirationMonth: data.expirationMonth, + expirationYear: data.expirationYear, + cvv: data.cvv + }; +} + + +function getSettings(settings, ref, valueName) { + if (settings !== null) { + return settings[valueName]; + } else if (ref !== null) { + return ref[valueName]; + } + return undefined; +} + +function getAccountOptions() { + let environment; + let settings = Packages.findOne({ + name: "reaction-braintree", + shopId: Reaction.getShopId(), + enabled: true + }).settings; + if (typeof settings !== "undefined" && settings !== null ? settings.mode : undefined === true) { + environment = "production"; + } else { + environment = "sandbox"; + } + + let ref = Meteor.settings.braintree; + let options = { + environment: environment, + merchantId: getSettings(settings, ref, "merchant_id"), + publicKey: getSettings(settings, ref, "public_key"), + privateKey: getSettings(settings, ref, "private_key") + }; + if (!options.merchantId) { + throw new Meteor.Error("invalid-credentials", "Invalid Braintree Credentials"); + } + return options; +} + +function getGateway() { + let accountOptions = getAccountOptions(); + if (accountOptions.environment === "production") { + accountOptions.environment = Braintree.Environment.Production; + } else { + accountOptions.environment = Braintree.Environment.Sandbox; + } + let gateway = Braintree.connect(accountOptions); + return gateway; +} + +getRefundDetails = function (refundId) { + check(refundId, String); + let gateway = getGateway(); + let braintreeFind = Meteor.wrapAsync(gateway.transaction.find, gateway.transaction); + let findResults = braintreeFind(refundId); + return findResults; +}; + + +BraintreeApi.apiCall.paymentSubmit = function (paymentSubmitDetails) { + let gateway = getGateway(); + let paymentObj = getPaymentObj(); + if (paymentSubmitDetails.transactionType === "authorize") { + paymentObj.options.submitForSettlement = false; + } + paymentObj.creditCard = parseCardData(paymentSubmitDetails.cardData); + paymentObj.amount = paymentSubmitDetails.paymentData.total; + let fut = new Future(); + gateway.transaction.sale(paymentObj, Meteor.bindEnvironment(function (error, result) { + if (error) { + fut.return({ + saved: false, + error: error + }); + } else if (!result.success) { + fut.return({ + saved: false, + response: result + }); + } else { + fut.return({ + saved: true, + response: result + }); + } + }, function (error) { + Reaction.Events.warn(error); + })); + + return fut.wait(); +}; + + +BraintreeApi.apiCall.captureCharge = function (paymentCaptureDetails) { + let transactionId = paymentCaptureDetails.transactionId; + let amount = accounting.toFixed(paymentCaptureDetails.amount, 2); + let gateway = getGateway(); + const fut = new Future(); + + if (amount === accounting.toFixed(0, 2)) { + gateway.transaction.void(transactionId, function (error, result) { + if (error) { + fut.return({ + saved: false, + error: error + }); + } else { + fut.return({ + saved: true, + response: result + }); + } + }, function (e) { + Logger.warn(e); + }); + return fut.wait(); + } + gateway.transaction.submitForSettlement(transactionId, amount, Meteor.bindEnvironment(function (error, result) { + if (error) { + fut.return({ + saved: false, + error: error + }); + } else { + fut.return({ + saved: true, + response: result + }); + } + }, function (e) { + Logger.warn(e); + })); + + return fut.wait(); +}; + + +BraintreeApi.apiCall.createRefund = function (refundDetails) { + let transactionId = refundDetails.transactionId; + let amount = refundDetails.amount; + let gateway = getGateway(); + const fut = new Future(); + gateway.transaction.refund(transactionId, amount, Meteor.bindEnvironment(function (error, result) { + if (error) { + fut.return({ + saved: false, + error: error + }); + } else if (!result.success) { + if (result.errors.errorCollections.transaction.validationErrors.base[0].code === "91506") { + fut.return({ + saved: false, + error: "Braintree does not allow refunds until transactions are settled. This can take up to 24 hours. Please try again later." + }); + } else { + fut.return({ + saved: false, + error: result.message + }); + } + } else { + fut.return({ + saved: true, + response: result + }); + } + }, function (e) { + Logger.fatal(e); + })); + return fut.wait(); +}; + + +BraintreeApi.apiCall.listRefunds = function (refundListDetails) { + let transactionId = refundListDetails.transactionId; + let gateway = getGateway(); + let braintreeFind = Meteor.wrapAsync(gateway.transaction.find, gateway.transaction); + let findResults = braintreeFind(transactionId); + let result = []; + if (findResults.refundIds.length > 0) { + for (let refund of findResults.refundIds) { + let refundDetails = getRefundDetails(refund); + result.push({ + type: "refund", + amount: parseFloat(refundDetails.amount), + created: moment(refundDetails.createdAt).unix() * 1000, + currency: refundDetails.currencyIsoCode, + raw: refundDetails + }); + } + } + + return result; +}; diff --git a/imports/plugins/included/braintree/server/methods/braintreeMethods.js b/imports/plugins/included/braintree/server/methods/braintreeMethods.js new file mode 100644 index 00000000000..2e6d2a1d96b --- /dev/null +++ b/imports/plugins/included/braintree/server/methods/braintreeMethods.js @@ -0,0 +1,154 @@ +import { BraintreeApi } from "./braintreeApi"; +import { Logger } from "/server/api"; +import { PaymentMethod } from "/lib/collections/schemas"; + +/** + * braintreeSubmit + * Authorize, or authorize and capture payments from Braintree + * https://developers.braintreepayments.com/reference/request/transaction/sale/node + * @param {String} transactionType - either authorize or capture + * @param {Object} cardData - Object containing everything about the Credit card to be submitted + * @param {Object} paymentData - Object containing everything about the transaction to be settled + * @return {Object} results - Object containing the results of the transaction + */ +export function paymentSubmit(transactionType, cardData, paymentData) { + check(transactionType, String); + check(cardData, { + name: String, + number: String, + expirationMonth: String, + expirationYear: String, + cvv2: String, + type: String + }); + check(paymentData, { + total: String, + currency: String + }); + + const paymentSubmitDetails = { + transactionType: transactionType, + cardData: cardData, + paymentData: paymentData + }; + + let result; + + try { + let paymentSubmitResult = BraintreeApi.apiCall.paymentSubmit(paymentSubmitDetails); + Logger.debug(paymentSubmitResult); + result = paymentSubmitResult; + } catch (error) { + Logger.error(error); + result = { + saved: false, + error: `Cannot Submit Payment: ${error.message}` + }; + Logger.fatal("Braintree call failed, payment was not submitted"); + } + + return result; +} + + +/** + * paymentCapture + * Capture payments from Braintree + * https://developers.braintreepayments.com/reference/request/transaction/submit-for-settlement/node + * @param {Object} paymentMethod - Object containing everything about the transaction to be settled + * @return {Object} results - Object containing the results of the transaction + */ +export function paymentCapture(paymentMethod) { + check(paymentMethod, PaymentMethod); + + const paymentCaptureDetails = { + transactionId: paymentMethod.transactionId, + amount: paymentMethod.amount + }; + + let result; + + try { + let paymentCaptureResult = BraintreeApi.apiCall.captureCharge(paymentCaptureDetails); + Logger.debug(paymentCaptureResult); + result = paymentCaptureResult; + } catch (error) { + Logger.error(error); + result = { + saved: false, + error: `Cannot Capture Payment: ${error.message}` + }; + Logger.fatal("Braintree call failed, payment was not captured"); + } + + return result; +} + + +/** + * createRefund + * Refund BrainTree payment + * https://developers.braintreepayments.com/reference/request/transaction/refund/node + * @param {Object} paymentMethod - Object containing everything about the transaction to be settled + * @param {Number} amount - Amount to be refunded if not the entire amount + * @return {Object} results - Object containing the results of the transaction + */ +export function createRefund(paymentMethod, amount) { + check(paymentMethod, PaymentMethod); + check(amount, Number); + + const refundDetails = { + transactionId: paymentMethod.transactionId, + amount: amount + }; + + let result; + + try { + let refundResult = BraintreeApi.apiCall.createRefund(refundDetails); + Logger.debug(refundResult); + result = refundResult; + } catch (error) { + Logger.error(error); + result = { + saved: false, + error: `Cannot issue refund: ${error.message}` + }; + Logger.fatal("Braintree call failed, refund was not issued"); + } + + return result; +} + + +/** + * listRefunds + * List all refunds for a transaction + * https://developers.braintreepayments.com/reference/request/transaction/find/node + * @param {Object} paymentMethod - Object containing everything about the transaction to be settled + * @return {Array} results - An array of refund objects for display in admin + */ +export function listRefunds(paymentMethod) { + check(paymentMethod, Object); + + const refundListDetails = { + transactionId: paymentMethod.transactionId + }; + + let result; + + try { + let refundListResult = BraintreeApi.apiCall.listRefunds(refundListDetails); + Logger.debug(refundListResult); + result = refundListResult; + } catch (error) { + Logger.error(error); + result = { + saved: false, + error: `Cannot list refunds: ${error.message}` + }; + Logger.fatal("Braintree call failed, refunds not listed"); + } + + return result; +} diff --git a/imports/plugins/included/braintree/server/methods/braintreeapi-methods-refund.app-test.js b/imports/plugins/included/braintree/server/methods/braintreeapi-methods-refund.app-test.js new file mode 100644 index 00000000000..b362fa672a8 --- /dev/null +++ b/imports/plugins/included/braintree/server/methods/braintreeapi-methods-refund.app-test.js @@ -0,0 +1,74 @@ +/* eslint camelcase: 0 */ +import { Meteor } from "meteor/meteor"; +import { expect } from "meteor/practicalmeteor:chai"; +import { sinon } from "meteor/practicalmeteor:sinon"; +import { BraintreeApi } from "./braintreeApi"; + +describe("braintree/refund/create", function () { + let sandbox; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it("Should call braintree/refund/create with the proper parameters and return saved = true", function (done) { + let paymentMethod = { + processor: "Braintree", + storedCard: "VISA 4242", + method: "Visa", + transactionId: "mqcp30p9", + amount: 99.95, + status: "completed", + mode: "capture", + createdAt: new Date(), + updatedAt: new Date(), + workflow: { + status: "new" + }, + metadata: {} + }; + + let braintreeRefundResult = { + saved: true, + response: { + transaction: { + id: "4yby45n6", + status: "submitted_for_settlement", + type: "credit", + currencyIsoCode: "USD", + amount: 99.95, + merchantAccountId: "ongoworks", + subMerchantAccountId: null, + masterMerchantAccountId: null, + orderId: null, + createdAt: "2016-08-10T01:34:55Z", + updatedAt: "2016-08-10T01:34:55Z" + } + } + }; + + sandbox.stub(BraintreeApi.apiCall, "createRefund", function () { + return braintreeRefundResult; + }); + + + let refundResult = null; + let refundError = null; + + + Meteor.call("braintree/refund/create", paymentMethod, paymentMethod.amount, function (error, result) { + refundResult = result; + refundError = error; + }); + + + expect(refundError).to.be.undefined; + expect(refundResult).to.not.be.undefined; + expect(refundResult.saved).to.be.true; + done(); + }); +}); diff --git a/private/data/i18n/en.json b/private/data/i18n/en.json index d853f717c84..5853924757e 100644 --- a/private/data/i18n/en.json +++ b/private/data/i18n/en.json @@ -340,6 +340,11 @@ "completed": "Completed", "canceled": "Canceled", "refunded": "Refunded" + }, + "paymentProvider": { + "braintree": { + "braintreeSettlementDelay": "Braintree does not allow refunds until transactions are settled. This can take up to 24 hours. Please try again later." + } } }, "orderShipping": { diff --git a/server/methods/core/orders.js b/server/methods/core/orders.js index 31b9b14f6c0..2345baf18d7 100644 --- a/server/methods/core/orders.js +++ b/server/methods/core/orders.js @@ -764,7 +764,7 @@ Meteor.methods({ }); if (result.saved === false) { - Logger.fatal("Attempt for refund transaction failed", order, paymentMethod.transactionId, result.error); + Logger.fatal("Attempt for refund transaction failed", order._id, paymentMethod.transactionId, result.error); throw new Meteor.Error( "Attempt to refund transaction failed", result.error);