Tax
diff --git a/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.html b/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.html
index e05e24bce26..50eae01ecdc 100644
--- a/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.html
+++ b/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.html
@@ -51,7 +51,7 @@
diff --git a/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js b/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js
index 125a53f981e..fa576a5d917 100644
--- a/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js
+++ b/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js
@@ -116,13 +116,21 @@ Template.coreOrderShippingInvoice.events({
const order = instance.state.get("order");
const orderTotal = order.billing[0].paymentMethod.amount;
const paymentMethod = order.billing[0].paymentMethod;
+ const discounts = order.billing[0].invoice.discounts;
const refund = state.get("field-refund") || 0;
const refunds = Template.instance().refunds.get();
let refundTotal = 0;
_.each(refunds, function (item) {
refundTotal += parseFloat(item.amount);
});
- const adjustedTotal = accounting.toFixed(orderTotal - refundTotal, 2);
+ let adjustedTotal;
+
+ // Stripe counts discounts as refunds, so we need to re-add the discount to not "double discount" in the adjustedTotal
+ if (paymentMethod.processor === "Stripe") {
+ adjustedTotal = accounting.toFixed(orderTotal + discounts - refundTotal, 2);
+ } else {
+ adjustedTotal = accounting.toFixed(orderTotal - refundTotal, 2);
+ }
if (refund > adjustedTotal) {
Alerts.inline("Refund(s) total cannot be greater than adjusted total", "error", {
@@ -316,12 +324,18 @@ Template.coreOrderShippingInvoice.helpers({
const instance = Template.instance();
const order = instance.state.get("order");
const paymentMethod = order.billing[0].paymentMethod;
+ const discounts = order.billing[0].invoice.discounts;
const refunds = Template.instance().refunds.get();
let refundTotal = 0;
+
_.each(refunds, function (item) {
refundTotal += parseFloat(item.amount);
});
- return paymentMethod.amount - refundTotal;
+
+ if (paymentMethod.processor === "Stripe") {
+ return Math.abs(paymentMethod.amount + discounts - refundTotal);
+ }
+ return Math.abs(paymentMethod.amount - refundTotal);
},
refundSubmitDisabled() {
diff --git a/imports/plugins/core/orders/client/templates/workflow/shippingTracking.js b/imports/plugins/core/orders/client/templates/workflow/shippingTracking.js
index 552d546ca1f..1588d2af072 100644
--- a/imports/plugins/core/orders/client/templates/workflow/shippingTracking.js
+++ b/imports/plugins/core/orders/client/templates/workflow/shippingTracking.js
@@ -37,7 +37,13 @@ Template.coreOrderShippingTracking.events({
"click [data-event-action=resendNotification]": function () {
let template = Template.instance();
- Meteor.call("orders/sendNotification", template.order);
+ Meteor.call("orders/sendNotification", template.order, (err) => {
+ if (err) {
+ Alerts.toast("Server Error: Can't send email notification.", "error");
+ } else {
+ Alerts.toast("Email notification sent.", "success");
+ }
+ });
},
"click [data-event-action=shipmentPacked]": () => {
diff --git a/imports/plugins/core/taxes/client/index.js b/imports/plugins/core/taxes/client/index.js
new file mode 100644
index 00000000000..243c29f0988
--- /dev/null
+++ b/imports/plugins/core/taxes/client/index.js
@@ -0,0 +1,4 @@
+import "./settings/custom.html";
+import "./settings/custom.js";
+import "./settings/settings.html";
+import "./settings/settings.js";
diff --git a/imports/plugins/core/taxes/client/settings/custom.html b/imports/plugins/core/taxes/client/settings/custom.html
new file mode 100644
index 00000000000..7469f2ad5e3
--- /dev/null
+++ b/imports/plugins/core/taxes/client/settings/custom.html
@@ -0,0 +1,97 @@
+
+
+ {{> React taxGrid }}
+
+
+
+ {{> React editButton }}
+
+
+ {{#if instance.state.get 'isEditing'}}
+
+ {{#if instance.state.get 'editingId'}}
+
+ {{#autoForm
+ schema=taxSchema
+ type="method-update"
+ meteormethod="taxes/addRate"
+ doc=taxRate
+ id="customTaxRates-update-form"
+ resetOnSuccess=true
+ }}
+
+
+ {{> afQuickField name='country' options=countryOptions class='form-control'}}
+
+ {{afFieldLabelText name='region'}}
+ {{#if statesForCountry}}
+ {{>afFieldInput name="region" value=region options=statesForCountry class='form-control'}}
+ {{else}}
+ {{>afFieldInput name="region" value=region class='form-control'}}
+ {{/if}}
+ {{#if afFieldIsInvalid name="region"}}
+ {{afFieldMessage name="region"}}
+ {{/if}}
+
+ {{> afQuickField name='postal' class='form-control' placeholder="Postal Code"}}
+ {{> afQuickField name='rate' class='form-control' placeholder="Rate as a percentage"}}
+
+ {{> taxSettingsSubmitButton instance=instance}}
+ {{/autoForm}}
+
+ {{else}}
+
+ {{#autoForm
+ schema=taxSchema
+ type="method"
+ meteormethod="taxes/addRate"
+ id="customTaxRates-insert-form"
+ doc=taxRate
+ resetOnSuccess=true
+ }}
+
+
+ {{> afQuickField name='country' options=countryOptions class='form-control'}}
+
+ {{afFieldLabelText name='region'}}
+ {{#if statesForCountry}}
+ {{>afFieldInput name="region" value=region options=statesForCountry class='form-control'}}
+ {{else}}
+ {{>afFieldInput name="region" value=region class='form-control'}}
+ {{/if}}
+ {{#if afFieldIsInvalid name="region"}}
+ {{afFieldMessage name="region" placeholder="State/Region/Province"}}
+ {{/if}}
+
+ {{> afQuickField name='postal' class='form-control' placeholder="Postal Code"}}
+ {{> afQuickField name='rate' class='form-control' placeholder="Rate as a percentage"}}
+
+ {{> taxSettingsSubmitButton instance=instance}}
+ {{/autoForm}}
+ {{/if}}
+
+ {{/if}}
+
+
+
+
+
+
+ Cancel
+ {{#if instance.state.get 'editingId'}}
+ Delete
+ {{/if}}
+ Save Changes
+
+
+
diff --git a/imports/plugins/core/taxes/client/settings/custom.js b/imports/plugins/core/taxes/client/settings/custom.js
new file mode 100644
index 00000000000..669edf65491
--- /dev/null
+++ b/imports/plugins/core/taxes/client/settings/custom.js
@@ -0,0 +1,259 @@
+import { Template } from "meteor/templating";
+import { ReactiveDict } from "meteor/reactive-dict";
+import { Shops } from "/lib/collections";
+import { Countries } from "/client/collections";
+import { Taxes, TaxCodes } from "../../lib/collections";
+import { i18next } from "/client/api";
+import { Taxes as TaxSchema } from "../../lib/collections/schemas";
+import MeteorGriddle from "/imports/plugins/core/ui-grid/client/griddle";
+import { IconButton } from "/imports/plugins/core/ui/client/components";
+
+/* eslint no-shadow: ["error", { "allow": ["options"] }] */
+/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "[oO]ptions" }] */
+
+Template.customTaxRates.onCreated(function () {
+ this.autorun(() => {
+ this.subscribe("Taxes");
+ });
+
+ this.state = new ReactiveDict();
+ this.state.setDefault({
+ isEditing: false,
+ editingId: null
+ });
+});
+
+Template.customTaxRates.helpers({
+ editButton() {
+ const instance = Template.instance();
+ const state = instance.state;
+ const isEditing = state.equals("isEditing", true);
+ let editingId = state.get("editingId");
+ // toggle edit state
+ if (!isEditing) {
+ editingId = null;
+ }
+ // return icon
+ return {
+ component: IconButton,
+ icon: "fa fa-plus",
+ onIcon: "fa fa-pencil",
+ toggle: true,
+ toggleOn: isEditing,
+ style: {
+ position: "relative",
+ top: "-25px",
+ right: "8px"
+ },
+ onClick() {
+ // remove active rows from grid
+ $(".tax-grid-row").removeClass("active");
+ return state.set({
+ isEditing: !isEditing,
+ editingId: editingId
+ });
+ }
+ };
+ },
+ taxGrid() {
+ const filteredFields = ["taxCode", "rate", "country", "region", "postal"];
+ const noDataMessage = i18next.t("taxSettings.noCustomTaxRatesFound");
+ const instance = Template.instance();
+
+ //
+ // helper to get and select row from griddle
+ // into blaze for to select tax row for editing
+ //
+ function editRow(options) {
+ const currentId = instance.state.get("editingId");
+ // isEditing is tax rate object
+ instance.state.set("isEditing", options.props.data);
+ instance.state.set("editingId", options.props.data._id);
+ // toggle edit mode clicking on same row
+ if (currentId === options.props.data._id) {
+ instance.state.set("isEditing", null);
+ instance.state.set("editingId", null);
+ }
+ }
+
+ //
+ // helper adds a class to every grid row
+ //
+ const customRowMetaData = {
+ bodyCssClassName: () => {
+ return "tax-grid-row";
+ }
+ };
+
+ // return tax Grid
+ return {
+ component: MeteorGriddle,
+ publication: "Taxes",
+ collection: Taxes,
+ matchingResultsCount: "taxes-count",
+ showFilter: true,
+ useGriddleStyles: false,
+ rowMetadata: customRowMetaData,
+ filteredFields: filteredFields,
+ columns: filteredFields,
+ noDataMessage: noDataMessage,
+ onRowClick: editRow
+ };
+ },
+
+ instance() {
+ const instance = Template.instance();
+ return instance;
+ },
+ // schema for forms
+ taxSchema() {
+ return TaxSchema;
+ },
+ // list of countries for tax input
+ countryOptions: function () {
+ return Countries.find().fetch();
+ },
+ statesForCountry: function () {
+ const shop = Shops.findOne();
+ const selectedCountry = AutoForm.getFieldValue("country");
+ if (!selectedCountry) {
+ return false;
+ }
+ if ((shop !== null ? shop.locales.countries[selectedCountry].states : void 0) === null) {
+ return false;
+ }
+ options = [];
+ if (shop && typeof shop.locales.countries[selectedCountry].states === "object") {
+ for (const state in shop.locales.countries[selectedCountry].states) {
+ if ({}.hasOwnProperty.call(shop.locales.countries[selectedCountry].states, state)) {
+ const locale = shop.locales.countries[selectedCountry].states[state];
+ options.push({
+ label: locale.name,
+ value: state
+ });
+ }
+ }
+ }
+ return options;
+ },
+ taxRate() {
+ const shop = Shops.findOne();
+ const instance = Template.instance();
+ const id = instance.state.get("editingId");
+ let tax = Taxes.findOne(id) || {};
+ // enforce a default country that makes sense.
+ if (!tax.country) {
+ if (shop && typeof shop.addressBook === "object") {
+ tax.country = shop.addressBook[0].country;
+ }
+ }
+ return tax;
+ },
+ taxCodes() {
+ const instance = Template.instance();
+ if (instance.subscriptionsReady()) {
+ const taxCodes = TaxCodes.find().fetch();
+ const options = [{
+ label: i18next.t("taxSettings.taxable"),
+ value: "RC_TAX"
+ }, {
+ label: i18next.t("taxSettings.nottaxable"),
+ value: "RC_NOTAX"
+ }];
+
+ for (let taxCode of taxCodes) {
+ options.push({
+ label: i18next.t(taxCode.label),
+ value: taxCode.id
+ });
+ }
+ return options;
+ }
+ return [];
+ }
+});
+
+//
+// on submit lets clear the form state
+//
+Template.customTaxRates.events({
+ "submit #customTaxRates-update-form": function () {
+ const instance = Template.instance();
+ instance.state.set({
+ isEditing: false,
+ editingId: null
+ });
+ },
+ "submit #customTaxRates-insert-form": function () {
+ const instance = Template.instance();
+ instance.state.set({
+ isEditing: true,
+ editingId: null
+ });
+ },
+ "click .cancel, .tax-grid-row .active": function () {
+ instance = Template.instance();
+ // remove active rows from grid
+ instance.state.set({
+ isEditing: false,
+ editingId: null
+ });
+ // ugly hack
+ $(".tax-grid-row").removeClass("active");
+ },
+ "click .delete": function () {
+ const confirmTitle = i18next.t("taxSettings.confirmRateDelete");
+ const confirmButtonText = i18next.t("app.delete");
+ const instance = Template.instance();
+ const id = instance.state.get("editingId");
+ // confirm delete
+ Alerts.alert({
+ title: confirmTitle,
+ type: "warning",
+ showCancelButton: true,
+ confirmButtonText: confirmButtonText
+ }, (isConfirm) => {
+ if (isConfirm) {
+ if (id) {
+ Meteor.call("taxes/deleteRate", id);
+ instance.state.set({
+ isEditing: false,
+ editingId: null
+ });
+ }
+ }
+ });
+ },
+ "click .tax-grid-row": function (event) {
+ // toggle all rows off, then add our active row
+ $(".tax-grid-row").removeClass("active");
+ $(event.currentTarget).addClass("active");
+ }
+});
+
+//
+// Hooks for update and insert forms
+//
+AutoForm.hooks({
+ "customTaxRates-update-form": {
+ onSuccess: function () {
+ return Alerts.toast(i18next.t("taxSettings.shopCustomTaxRatesSaved"),
+ "success");
+ },
+ onError: function (operation, error) {
+ return Alerts.toast(
+ `${i18next.t("taxSettings.shopCustomTaxRatesFailed")} ${error}`, "error"
+ );
+ }
+ },
+ "customTaxRates-insert-form": {
+ onSuccess: function () {
+ return Alerts.toast(i18next.t("taxSettings.shopCustomTaxRatesSaved"), "success");
+ },
+ onError: function (operation, error) {
+ return Alerts.toast(
+ `${i18next.t("taxSettings.shopCustomTaxRatesFailed")} ${error}`, "error"
+ );
+ }
+ }
+});
diff --git a/imports/plugins/core/taxes/client/settings/settings.html b/imports/plugins/core/taxes/client/settings/settings.html
new file mode 100644
index 00000000000..50b945e4c50
--- /dev/null
+++ b/imports/plugins/core/taxes/client/settings/settings.html
@@ -0,0 +1,29 @@
+
+
+
+ {{#each reactionApps provides='taxSettings'}}
+
+
+
+
+
+ {{label}}
+
+
+
+
+
+
+
+ {{> Template.dynamic template=template data=.}}
+
+
+ {{/each}}
+
+
diff --git a/imports/plugins/core/taxes/client/settings/settings.js b/imports/plugins/core/taxes/client/settings/settings.js
new file mode 100644
index 00000000000..eea7bd5cc92
--- /dev/null
+++ b/imports/plugins/core/taxes/client/settings/settings.js
@@ -0,0 +1,118 @@
+import { Template } from "meteor/templating";
+import { Packages } from "/lib/collections";
+import { TaxCodes } from "../../lib/collections";
+import { i18next } from "/client/api";
+import { TaxPackageConfig } from "../../lib/collections/schemas";
+
+/*
+ * Template taxes Helpers
+ */
+Template.taxSettings.onCreated(function () {
+ this.autorun(() => {
+ this.subscribe("TaxCodes");
+ });
+});
+
+Template.taxSettings.helpers({
+ packageConfigSchema() {
+ return TaxPackageConfig;
+ },
+ //
+ // check if this package setting is enabled
+ //
+ checked(pkg) {
+ let enabled;
+ const pkgData = Packages.findOne(pkg.packageId);
+ const setting = pkg.name.split("/").splice(-1);
+
+ if (pkgData && pkgData.settings) {
+ if (pkgData.settings[setting]) {
+ enabled = pkgData.settings[setting].enabled;
+ }
+ }
+ return enabled === true ? "checked" : "";
+ },
+ //
+ // get current packages settings data
+ //
+ packageData() {
+ return Packages.findOne({
+ name: "reaction-taxes"
+ });
+ },
+ //
+ // prepare and return taxCodes
+ // for default shop value
+ //
+ taxCodes() {
+ const instance = Template.instance();
+ if (instance.subscriptionsReady()) {
+ const taxCodes = TaxCodes.find().fetch();
+ const options = [{
+ label: i18next.t("app.auto"),
+ value: "none"
+ }];
+
+ for (let taxCode of taxCodes) {
+ options.push({
+ label: i18next.t(taxCode.label),
+ value: taxCode.id
+ });
+ }
+ return options;
+ }
+ return undefined;
+ },
+ //
+ // Template helper to add a hidden class if the condition is false
+ //
+ shown(pkg) {
+ let enabled;
+ const pkgData = Packages.findOne(pkg.packageId);
+ const setting = pkg.name.split("/").splice(-1);
+
+ if (pkgData && pkgData.settings) {
+ if (pkgData.settings[setting]) {
+ enabled = pkgData.settings[setting].enabled;
+ }
+ }
+
+ return enabled !== true ? "hidden" : "";
+ }
+});
+
+Template.taxSettings.events({
+ /**
+ * taxSettings settings update enabled status for tax service on change
+ * @param {event} event jQuery Event
+ * @return {void}
+ */
+ "change input[name=enabled]": (event) => {
+ const name = event.target.value;
+ const packageId = event.target.getAttribute("data-id");
+ const fields = [{
+ property: "enabled",
+ value: event.target.checked
+ }];
+
+ Meteor.call("registry/update", packageId, name, fields);
+ },
+
+ /**
+ * taxSettings settings show/hide secret key for a tax service
+ * @param {event} event jQuery Event
+ * @return {void}
+ */
+ "click [data-event-action=showSecret]": (event) => {
+ let button = $(event.currentTarget);
+ let input = button.closest(".form-group").find("input[name=secret]");
+
+ if (input.attr("type") === "password") {
+ input.attr("type", "text");
+ button.html("Hide");
+ } else {
+ input.attr("type", "password");
+ button.html("Show");
+ }
+ }
+});
diff --git a/imports/plugins/core/taxes/lib/collections/collections.js b/imports/plugins/core/taxes/lib/collections/collections.js
new file mode 100644
index 00000000000..b3e3a3d89af
--- /dev/null
+++ b/imports/plugins/core/taxes/lib/collections/collections.js
@@ -0,0 +1,21 @@
+import { Mongo } from "meteor/mongo";
+import * as Schemas from "./schemas";
+
+/**
+* ReactionCore Collections TaxCodes
+*/
+
+/**
+* Taxes Collection
+*/
+export const Taxes = new Mongo.Collection("Taxes");
+
+Taxes.attachSchema(Schemas.Taxes);
+
+
+/**
+* TaxCodes Collection
+*/
+export const TaxCodes = new Mongo.Collection("TaxCodes");
+
+TaxCodes.attachSchema(Schemas.TaxCodes);
diff --git a/imports/plugins/core/taxes/lib/collections/index.js b/imports/plugins/core/taxes/lib/collections/index.js
new file mode 100644
index 00000000000..45e94450ebd
--- /dev/null
+++ b/imports/plugins/core/taxes/lib/collections/index.js
@@ -0,0 +1 @@
+export * from "./collections";
diff --git a/imports/plugins/core/taxes/lib/collections/schemas/config.js b/imports/plugins/core/taxes/lib/collections/schemas/config.js
new file mode 100644
index 00000000000..b8eea006029
--- /dev/null
+++ b/imports/plugins/core/taxes/lib/collections/schemas/config.js
@@ -0,0 +1,37 @@
+import { SimpleSchema } from "meteor/aldeed:simple-schema";
+import { PackageConfig } from "/lib/collections/schemas/registry";
+import { Taxes } from "./taxes";
+
+/**
+* TaxPackageConfig Schema
+*/
+
+export const TaxPackageConfig = new SimpleSchema([
+ PackageConfig, {
+ "settings.defaultTaxCode": {
+ type: String,
+ optional: true
+ },
+ "settings.taxIncluded": {
+ type: Boolean,
+ defaultValue: false
+ },
+ "settings.taxShipping": {
+ type: Boolean,
+ defaultValue: false
+ },
+ "settings.rates": {
+ type: Object,
+ optional: true
+ },
+ "settings.rates.enabled": {
+ type: Boolean,
+ optional: true,
+ defaultValue: false
+ },
+ "settings.rates.taxes": {
+ type: [Taxes],
+ optional: true
+ }
+ }
+]);
diff --git a/imports/plugins/core/taxes/lib/collections/schemas/index.js b/imports/plugins/core/taxes/lib/collections/schemas/index.js
new file mode 100644
index 00000000000..1223330167d
--- /dev/null
+++ b/imports/plugins/core/taxes/lib/collections/schemas/index.js
@@ -0,0 +1,4 @@
+export * from "./taxrates";
+export * from "./taxes";
+export * from "./taxcodes";
+export * from "./config";
diff --git a/imports/plugins/core/taxes/lib/collections/schemas/taxcodes.js b/imports/plugins/core/taxes/lib/collections/schemas/taxcodes.js
new file mode 100644
index 00000000000..fdacd208696
--- /dev/null
+++ b/imports/plugins/core/taxes/lib/collections/schemas/taxcodes.js
@@ -0,0 +1,38 @@
+import { SimpleSchema } from "meteor/aldeed:simple-schema";
+
+/**
+* TaxCodes Schema
+*/
+
+export const TaxCodes = new SimpleSchema({
+ id: {
+ type: String,
+ label: "Tax Id",
+ unique: true
+ },
+ shopId: {
+ type: String,
+ optional: true
+ },
+ ssuta: {
+ type: Boolean,
+ label: "Streamlined Sales Tax"
+ },
+ title: {
+ type: String,
+ optional: true
+ },
+ label: {
+ type: String,
+ optional: true
+ },
+ parent: {
+ type: String,
+ optional: true
+ },
+ children: {
+ type: [Object],
+ optional: true,
+ blackbox: true
+ }
+});
diff --git a/imports/plugins/core/taxes/lib/collections/schemas/taxes.js b/imports/plugins/core/taxes/lib/collections/schemas/taxes.js
new file mode 100644
index 00000000000..d3909d22678
--- /dev/null
+++ b/imports/plugins/core/taxes/lib/collections/schemas/taxes.js
@@ -0,0 +1,97 @@
+import { SimpleSchema } from "meteor/aldeed:simple-schema";
+import { shopIdAutoValue } from "/lib/collections/schemas/helpers";
+
+/**
+* Taxes Schema
+*/
+
+export const Taxes = new SimpleSchema({
+ "shopId": {
+ type: String,
+ autoValue: shopIdAutoValue,
+ index: 1,
+ label: "Taxes shopId"
+ },
+ "taxCode": {
+ type: String,
+ label: "Tax Identifier",
+ defaultValue: "RC_TAX",
+ index: 1
+ },
+ "cartMethod": {
+ label: "Calculation Method",
+ type: String,
+ allowedValues: ["unit", "row", "total"],
+ defaultValue: "total"
+ },
+ "taxLocale": {
+ label: "Taxation Location",
+ type: String,
+ allowedValues: ["shipping", "billing", "origination", "destination"],
+ defaultValue: "destination"
+ },
+ "taxShipping": {
+ label: "Tax Shipping",
+ type: Boolean,
+ defaultValue: false
+ },
+ "taxIncluded": {
+ label: "Taxes included in product prices",
+ type: Boolean,
+ defaultValue: false,
+ optional: true
+ },
+ "discountsIncluded": {
+ label: "Tax before discounts",
+ type: Boolean,
+ defaultValue: false,
+ optional: true
+ },
+ "region": {
+ label: "State/Province/Region",
+ type: String,
+ optional: true,
+ index: 1
+ },
+ "postal": {
+ label: "ZIP/Postal Code",
+ type: String,
+ optional: true,
+ index: 1
+ },
+ "country": {
+ type: String,
+ label: "Country",
+ optional: true,
+ index: 1
+ },
+ "isCommercial": {
+ label: "Commercial address.",
+ type: Boolean,
+ optional: true
+ },
+ "rate": {
+ type: Number,
+ decimal: true
+ },
+ "method": {
+ type: Array,
+ optional: true,
+ label: "Tax Methods"
+ },
+ "method.$": {
+ type: Object
+ },
+ "method.$.plugin": {
+ type: String,
+ label: "Plugin",
+ defaultValue: "Custom",
+ optional: true
+ },
+ "method.$.enabled": {
+ type: Boolean,
+ label: "Enabled",
+ defaultValue: true,
+ optional: true
+ }
+});
diff --git a/imports/plugins/core/taxes/lib/collections/schemas/taxrates.js b/imports/plugins/core/taxes/lib/collections/schemas/taxrates.js
new file mode 100644
index 00000000000..c33764f86fa
--- /dev/null
+++ b/imports/plugins/core/taxes/lib/collections/schemas/taxrates.js
@@ -0,0 +1,23 @@
+import { SimpleSchema } from "meteor/aldeed:simple-schema";
+
+/**
+* TaxRates Schema
+*/
+
+export const TaxRates = new SimpleSchema({
+ country: {
+ type: String
+ },
+ county: {
+ type: String,
+ optional: true
+ },
+ postal: {
+ type: String,
+ optional: true
+ },
+ rate: {
+ type: Number,
+ decimal: true
+ }
+});
diff --git a/imports/plugins/core/taxes/register.js b/imports/plugins/core/taxes/register.js
new file mode 100644
index 00000000000..751761e1483
--- /dev/null
+++ b/imports/plugins/core/taxes/register.js
@@ -0,0 +1,44 @@
+import { Reaction } from "/server/api";
+
+Reaction.registerPackage({
+ label: "Taxes",
+ name: "reaction-taxes",
+ icon: "fa fa-university",
+ autoEnable: true,
+ settings: {
+ custom: {
+ enabled: true
+ },
+ rates: {
+ enabled: false
+ }
+ },
+ registry: [
+ {
+ provides: "dashboard",
+ name: "taxes",
+ label: "Taxes",
+ description: "Provide tax rates",
+ icon: "fa fa-university",
+ priority: 3,
+ container: "core",
+ workflow: "coreDashboardWorkflow"
+ },
+ {
+ label: "Tax Settings",
+ name: "taxes/settings",
+ provides: "settings",
+ template: "taxSettings"
+ },
+ {
+ label: "Custom Rates",
+ name: "taxes/settings/rates",
+ provides: "taxSettings",
+ template: "customTaxRates"
+ },
+ {
+ template: "flatRateCheckoutTaxes",
+ provides: "taxMethod"
+ }
+ ]
+});
diff --git a/imports/plugins/core/taxes/server/api/import.js b/imports/plugins/core/taxes/server/api/import.js
new file mode 100644
index 00000000000..cfb158fbea6
--- /dev/null
+++ b/imports/plugins/core/taxes/server/api/import.js
@@ -0,0 +1,20 @@
+import { Reaction } from "/server/api";
+import Import from "/server/api/core/import";
+import * as Collections from "../../lib/collections";
+
+// plugin Import helpers
+const TaxImport = Import;
+
+// Import helper to store a taxCode in the import buffer.
+TaxImport.taxCode = function (key, taxCode) {
+ return this.object(Collections.TaxCodes, key, taxCode);
+};
+
+// configure Import key detection
+TaxImport.indication("ssuta", Collections.TaxCodes, 0.5);
+
+// should assign to global
+Object.assign(Reaction.Import, TaxImport);
+
+// exports Reaction.Import with new taxcode helper
+export default Reaction;
diff --git a/imports/plugins/core/taxes/server/api/index.js b/imports/plugins/core/taxes/server/api/index.js
new file mode 100644
index 00000000000..4b7505b122f
--- /dev/null
+++ b/imports/plugins/core/taxes/server/api/index.js
@@ -0,0 +1,3 @@
+import Reaction from "./import";
+
+export default Reaction;
diff --git a/imports/plugins/core/taxes/server/hooks/collections.js b/imports/plugins/core/taxes/server/hooks/collections.js
new file mode 100644
index 00000000000..d0932af5ff0
--- /dev/null
+++ b/imports/plugins/core/taxes/server/hooks/collections.js
@@ -0,0 +1,46 @@
+import { Cart } from "/lib/collections";
+import { Logger } from "/server/api";
+/**
+ * Taxes Collection Hooks
+*/
+
+/**
+ * After cart update apply taxes.
+ * if items are changed, recalculating taxes
+ * we could have done this in the core/cart transform
+ * but this way this file controls the events from
+ * the core/taxes plugin.
+ */
+Cart.after.update((userId, cart, fieldNames, modifier) => {
+ // adding quantity
+ if (modifier.$inc) {
+ Logger.debug("incrementing cart - recalculating taxes");
+ Meteor.call("taxes/calculate", cart._id);
+ }
+
+ // adding new items
+ if (modifier.$addToSet) {
+ if (modifier.$addToSet.items) {
+ Logger.debug("adding to cart - recalculating taxes");
+ Meteor.call("taxes/calculate", cart._id);
+ }
+ }
+
+ // altering the cart shipping
+ // or billing address we'll update taxes
+ // ie: shipping/getShippingRates
+ if (modifier.$set) {
+ if (modifier.$set["shipping.$.shipmentMethod"] || modifier.$set["shipping.$.address"]) {
+ Logger.debug("updated shipping info - recalculating taxes");
+ Meteor.call("taxes/calculate", cart._id);
+ }
+ }
+
+ // removing items
+ if (modifier.$pull) {
+ if (modifier.$pull.items) {
+ Logger.debug("removing from cart - recalculating taxes");
+ Meteor.call("taxes/calculate", cart._id);
+ }
+ }
+});
diff --git a/imports/plugins/core/taxes/server/hooks/index.js b/imports/plugins/core/taxes/server/hooks/index.js
new file mode 100644
index 00000000000..0c4003816c7
--- /dev/null
+++ b/imports/plugins/core/taxes/server/hooks/index.js
@@ -0,0 +1 @@
+import "./collections";
diff --git a/imports/plugins/core/taxes/server/index.js b/imports/plugins/core/taxes/server/index.js
new file mode 100644
index 00000000000..6f749bf896d
--- /dev/null
+++ b/imports/plugins/core/taxes/server/index.js
@@ -0,0 +1,4 @@
+// assemble server api
+import "./methods/methods";
+import "./publications/taxes";
+import "./hooks/collections";
diff --git a/imports/plugins/core/taxes/server/methods/methods.app-test.js b/imports/plugins/core/taxes/server/methods/methods.app-test.js
new file mode 100644
index 00000000000..c039a7e76dd
--- /dev/null
+++ b/imports/plugins/core/taxes/server/methods/methods.app-test.js
@@ -0,0 +1,25 @@
+import { Meteor } from "meteor/meteor";
+import { Roles } from "meteor/alanning:roles";
+import { expect } from "meteor/practicalmeteor:chai";
+import { sinon } from "meteor/practicalmeteor:sinon";
+
+describe("taxes methods", function () {
+ let sandbox;
+
+ beforeEach(function () {
+ sandbox = sinon.sandbox.create();
+ });
+
+ afterEach(function () {
+ sandbox.restore();
+ });
+
+ describe("taxes/deleteRate", function () {
+ it("should throw 403 error with taxes permission", function (done) {
+ sandbox.stub(Roles, "userIsInRole", () => false);
+ // this should actually trigger a whole lot of things
+ expect(() => Meteor.call("taxes/deleteRate", "dummystring")).to.throw(Meteor.Error, /Access Denied/);
+ return done();
+ });
+ });
+});
diff --git a/imports/plugins/core/taxes/server/methods/methods.js b/imports/plugins/core/taxes/server/methods/methods.js
new file mode 100644
index 00000000000..f9d6da9970d
--- /dev/null
+++ b/imports/plugins/core/taxes/server/methods/methods.js
@@ -0,0 +1,170 @@
+import { Meteor } from "meteor/meteor";
+import { Match, check } from "meteor/check";
+import { Cart, Packages } from "/lib/collections";
+import { Taxes } from "../../lib/collections";
+import Reaction from "../api";
+import { Logger } from "/server/api";
+
+//
+// make all tax methods available
+//
+export const methods = {
+ /**
+ * taxes/deleteRate
+ * @param {String} taxId tax taxId to delete
+ * @return {String} returns update/insert result
+ */
+ "taxes/deleteRate": function (taxId) {
+ check(taxId, String);
+
+ // check permissions to delete
+ if (!Reaction.hasPermission("taxes")) {
+ throw new Meteor.Error(403, "Access Denied");
+ }
+
+ return Taxes.remove(taxId);
+ },
+
+ /**
+ * taxes/setRate
+ * @param {String} cartId cartId
+ * @param {Number} taxRate taxRate
+ * @param {Object} taxes taxes
+ * @return {Number} returns update result
+ */
+ "taxes/setRate": function (cartId, taxRate, taxes) {
+ check(cartId, String);
+ check(taxRate, Number);
+ check(taxes, Match.Optional(Array));
+
+ return Cart.update(cartId, {
+ $set: {
+ taxes: taxes,
+ tax: taxRate
+ }
+ });
+ },
+
+ /**
+ * taxes/addRate
+ * @param {String} modifier update statement
+ * @param {String} docId tax docId
+ * @return {String} returns update/insert result
+ */
+ "taxes/addRate": function (modifier, docId) {
+ check(modifier, Object);
+ check(docId, Match.OneOf(String, null, undefined));
+
+ // check permissions to add
+ if (!Reaction.hasPermission("taxes")) {
+ throw new Meteor.Error(403, "Access Denied");
+ }
+ // if no doc, insert
+ if (!docId) {
+ return Taxes.insert(modifier);
+ }
+ // else update and return
+ return Taxes.update(docId, modifier);
+ },
+
+ /**
+ * taxes/calculate
+ * @param {String} cartId cartId
+ * @return {Object} returns tax object
+ */
+ "taxes/calculate": function (cartId) {
+ check(cartId, String);
+ const cartToCalc = Cart.findOne(cartId);
+ const shopId = cartToCalc.shopId;
+ let taxRate = 0;
+ // get all tax packages
+ //
+ // TODO FIND IN LAYOUT/REGISTRY
+ //
+ const pkg = Packages.findOne({
+ shopId: shopId,
+ name: "reaction-taxes"
+ });
+ //
+ // custom rates
+ // TODO Determine calculation method (row, total, shipping)
+ // TODO method for order tax updates
+ // additional logic will be needed for refunds
+ // or tax adjustments
+ //
+ // check if plugin is enabled and this calculation method is enabled
+ if (pkg && pkg.enabled === true && pkg.settings.rates.enabled === true) {
+ Logger.info("Calculating custom tax rates");
+
+ if (typeof cartToCalc.shipping !== "undefined") {
+ const shippingAddress = cartToCalc.shipping[0].address;
+ //
+ // custom rates that match shipping info
+ // high chance this needs more review as
+ // it's unlikely this matches all potential
+ // here we just sort by postal, so if it's an exact
+ // match we're taking the first record, where the most
+ // likely tax scenario is a postal code falling
+ // back to a regional tax.
+
+ if (shippingAddress) {
+ let customTaxRate = 0;
+ let totalTax = 0;
+ // lookup custom tax rate
+ let addressTaxData = Taxes.find(
+ {
+ $and: [{
+ $or: [{
+ postal: shippingAddress.postal
+ }, {
+ postal: { $exists: false },
+ region: shippingAddress.region,
+ country: shippingAddress.country
+ }, {
+ postal: { $exists: false },
+ region: { $exists: false },
+ country: shippingAddress.country
+ }]
+ }, {
+ shopId: shopId
+ }]
+ }, {sort: { postal: -1 } }
+ ).fetch();
+
+ // return custom rates
+ // TODO break down the product origination, taxability
+ // by qty and an originating shop and inventory
+ // for location of each item in the cart.
+ if (addressTaxData.length > 0) {
+ customTaxRate = addressTaxData[0].rate;
+ }
+
+ // calculate line item taxes
+ for (let items of cartToCalc.items) {
+ // only processs taxable products
+ if (items.variants.taxable === true) {
+ const subTotal = items.variants.price * items.quantity;
+ const tax = subTotal * (customTaxRate / 100);
+ totalTax += tax;
+ }
+ }
+ // calculate overall cart rate
+ if (totalTax > 0) {
+ taxRate = (totalTax / cartToCalc.cartSubTotal());
+ }
+ // store tax on cart
+ Meteor.call("taxes/setRate", cartToCalc._id, taxRate, addressTaxData);
+ } // end custom rates
+ } // end shippingAddress calculation
+ } else {
+ // we are here because the custom rate package is disabled.
+ // we're going to set an inital rate of 0
+ // all methods that trigger when taxes/calculate will
+ // recalculate this rate as needed.
+ Meteor.call("taxes/setRate", cartToCalc._id, taxRate);
+ }
+ } // end taxes/calculate
+};
+
+// export tax methods to Meteor
+Meteor.methods(methods);
diff --git a/imports/plugins/core/taxes/server/publications/taxes.js b/imports/plugins/core/taxes/server/publications/taxes.js
new file mode 100644
index 00000000000..a7027cb6376
--- /dev/null
+++ b/imports/plugins/core/taxes/server/publications/taxes.js
@@ -0,0 +1,92 @@
+import { Meteor } from "meteor/meteor";
+import { Match, check} from "meteor/check";
+import { Counts } from "meteor/tmeasday:publish-counts";
+import { Taxes, TaxCodes } from "../../lib/collections";
+import { Reaction } from "/server/api";
+
+//
+// Security
+// import "/server/security/collections";
+// Security definitions
+//
+Security.permit(["insert", "update", "remove"]).collections([
+ Taxes,
+ TaxCodes
+]).ifHasRole({
+ role: "admin",
+ group: Reaction.getShopId()
+});
+/**
+ * taxes
+ */
+Meteor.publish("Taxes", function (query, options) {
+ check(query, Match.Optional(Object));
+ check(options, Match.Optional(Object));
+
+ // check shopId
+ const shopId = Reaction.getShopId();
+ if (!shopId) {
+ return this.ready();
+ }
+
+ const select = query || {};
+ // append shopId to query
+ // taxes could be shared
+ // if you disregarded shopId
+ select.shopId = shopId;
+
+ // appends a count to the collection
+ // we're doing this for use with griddleTable
+ Counts.publish(this, "taxes-count", Taxes.find(
+ select,
+ options
+ ));
+
+ return Taxes.find(
+ select,
+ options
+ );
+});
+
+/**
+ * tax codes
+ */
+Meteor.publish("TaxCodes", function (query, params) {
+ check(query, Match.Optional(Object));
+ check(params, Match.Optional(Object));
+
+ // check shopId
+ const shopId = Reaction.getShopId();
+ if (!shopId) {
+ return this.ready();
+ }
+
+ const select = query || {};
+
+ // for now, not adding shopId to query
+ // taxCodes are reasonable shared??
+ // select.shopId = shopId;
+
+ const options = params || {};
+ // const options = params || {
+ // fields: {
+ // id: 1,
+ // label: 1
+ // },
+ // sort: {
+ // label: 1
+ // }
+ // };
+
+ // appends a count to the collection
+ // we're doing this for use with griddleTable
+ Counts.publish(this, "taxcode-count", TaxCodes.find(
+ select,
+ options
+ ));
+
+ return TaxCodes.find(
+ select,
+ options
+ );
+});
diff --git a/imports/plugins/core/ui-grid/client/griddle.js b/imports/plugins/core/ui-grid/client/griddle.js
new file mode 100644
index 00000000000..33873d8a331
--- /dev/null
+++ b/imports/plugins/core/ui-grid/client/griddle.js
@@ -0,0 +1,149 @@
+/*
+Forked from https://github.com/meteor-utilities/Meteor-Griddle
+ */
+import React from "react";
+import _ from "lodash";
+import Griddle from "griddle-react";
+import { Counts } from "meteor/tmeasday:publish-counts";
+import { ReactMeteorData } from "meteor/react-meteor-data";
+
+/* eslint react/prop-types:0, react/jsx-sort-props:0, react/forbid-prop-types: 0, "react/prefer-es6-class": [1, "never"] */
+
+const MeteorGriddle = React.createClass({
+ propTypes: {
+ collection: React.PropTypes.object, // the collection to display
+ filteredFields: React.PropTypes.array, // an array of fields to search through when filtering
+ matchingResultsCount: React.PropTypes.string, // the name of the matching results counter
+ publication: React.PropTypes.string, // the publication that will provide the data
+ subsManager: React.PropTypes.object
+ },
+ mixins: [ReactMeteorData],
+
+ getDefaultProps() {
+ return {useExternal: false, externalFilterDebounceWait: 300, externalResultsPerPage: 10};
+ },
+
+ getInitialState() {
+ return {
+ currentPage: 0,
+ maxPages: 0,
+ externalResultsPerPage: this.props.externalResultsPerPage,
+ externalSortColumn: this.props.externalSortColumn,
+ externalSortAscending: this.props.externalSortAscending,
+ query: {}
+ };
+ },
+
+ componentWillMount() {
+ this.applyQuery = _.debounce((query) => {
+ this.setState({query});
+ }, this.props.externalFilterDebounceWait);
+ },
+
+ getMeteorData() {
+ // Get a count of the number of items matching the current filter. If no filter is set it will return the total number
+ // of items in the collection.
+ const matchingResults = Counts.get(this.props.matchingResultsCount);
+
+ const options = {};
+ let skip;
+ if (this.props.useExternal) {
+ options.limit = this.state.externalResultsPerPage;
+ if (!_.isEmpty(this.state.query) && !!matchingResults) {
+ // if necessary, limit the cursor to number of matching results to avoid displaying results from other publications
+ options.limit = _.min([options.limit, matchingResults]);
+ }
+ options.sort = {
+ [this.state.externalSortColumn]: (this.state.externalSortAscending
+ ? 1
+ : -1)
+ };
+ skip = this.state.currentPage * this.state.externalResultsPerPage;
+ }
+
+ let pubHandle;
+
+ if (this.props.subsManager) {
+ pubHandle = this.props.subsManager.subscribe(this.props.publication, this.state.query, _.extend({
+ skip: skip
+ }, options));
+ } else {
+ pubHandle = Meteor.subscribe(this.props.publication, this.state.query, _.extend({
+ skip: skip
+ }, options));
+ }
+
+ const results = this.props.collection.find(this.state.query, options).fetch();
+
+ return {
+ loading: !pubHandle.ready(),
+ results: results,
+ matchingResults: matchingResults
+ };
+ },
+
+ resetQuery() {
+ this.setState({query: {}});
+ },
+
+ // what page is currently viewed
+ setPage(index) {
+ this.setState({currentPage: index});
+ },
+
+ // this changes whether data is sorted in ascending or descending order
+ changeSort(sort, sortAscending) {
+ this.setState({externalSortColumn: sort, externalSortAscending: sortAscending});
+ },
+
+ setFilter(filter) {
+ if (filter) {
+ const filteredFields = this.props.filteredFields || this.props.columns;
+ const orArray = filteredFields.map((field) => {
+ const filterItem = {};
+ filterItem[field] = {
+ $regex: filter,
+ $options: "i"
+ };
+ return filterItem;
+ });
+ this.applyQuery({$or: orArray});
+ } else {
+ this.resetQuery();
+ }
+ },
+
+ // this method handles determining the page size
+ setPageSize(size) {
+ this.setState({externalResultsPerPage: size});
+ },
+
+ render() {
+ // figure out how many pages we have based on the number of total results matching the cursor
+ const maxPages = Math.ceil(this.data.matchingResults / this.state.externalResultsPerPage);
+
+ // The Griddle externalIsLoading property is managed internally to line up with the subscription ready state, so we're
+ // removing this property if it's passed in.
+ const allProps = this.props;
+ delete allProps.externalIsLoading;
+
+ return (
);
+ }
+});
+
+export default MeteorGriddle;
diff --git a/imports/plugins/core/ui-grid/client/index.js b/imports/plugins/core/ui-grid/client/index.js
new file mode 100644
index 00000000000..7d935878c0e
--- /dev/null
+++ b/imports/plugins/core/ui-grid/client/index.js
@@ -0,0 +1 @@
+import "./griddle.js";
diff --git a/imports/plugins/core/ui-grid/register.js b/imports/plugins/core/ui-grid/register.js
new file mode 100644
index 00000000000..617c821d08a
--- /dev/null
+++ b/imports/plugins/core/ui-grid/register.js
@@ -0,0 +1,7 @@
+import { Reaction } from "/server/api";
+
+Reaction.registerPackage({
+ label: "UI Grid",
+ name: "reaction-ui-grid",
+ autoEnable: true
+});
diff --git a/imports/plugins/included/authnet/lib/collections/schemas/package.js b/imports/plugins/included/authnet/lib/collections/schemas/package.js
index dbf5a91040f..52a7e9f1c7e 100644
--- a/imports/plugins/included/authnet/lib/collections/schemas/package.js
+++ b/imports/plugins/included/authnet/lib/collections/schemas/package.js
@@ -37,7 +37,8 @@ export const AuthNetPayment = new SimpleSchema({
cardNumber: {
type: String,
label: "Card number",
- min: 16
+ min: 12,
+ max: 19
},
expireMonth: {
type: String,
diff --git a/imports/plugins/included/authnet/server/methods/authnet.js b/imports/plugins/included/authnet/server/methods/authnet.js
index 571ffeac628..726c7ba0aab 100644
--- a/imports/plugins/included/authnet/server/methods/authnet.js
+++ b/imports/plugins/included/authnet/server/methods/authnet.js
@@ -1,6 +1,7 @@
/* eslint camelcase: 0 */
/* eslint quote-props: 0 */
// meteor modules
+import accounting from "accounting-js";
import { Meteor } from "meteor/meteor";
import { check, Match } from "meteor/check";
import { Promise } from "meteor/promise";
@@ -8,6 +9,7 @@ import { Promise } from "meteor/promise";
import AuthNetAPI from "authorize-net";
import { Reaction, Logger } from "/server/api";
import { Packages } from "/lib/collections";
+import { PaymentMethod } from "/lib/collections/schemas";
function getAccountOptions() {
let settings = Packages.findOne({
@@ -90,7 +92,33 @@ Meteor.methods({
const authnetService = getAuthnetService(getAccountOptions());
const roundedAmount = parseFloat(amount.toFixed(2));
+ const capturedAmount = accounting.toFixed(amount, 2);
let result;
+ if (capturedAmount === accounting.toFixed(0, 2)) {
+ try {
+ const captureResult = voidTransaction(transactionId,
+ authnetService
+ );
+ if (captureResult.responseCode[0] === "1") {
+ result = {
+ saved: true,
+ response: captureResult
+ };
+ } else {
+ result = {
+ saved: false,
+ error: captureResult
+ };
+ }
+ } catch (error) {
+ Logger.fatal(error);
+ result = {
+ saved: false,
+ error: error
+ };
+ }
+ return result;
+ }
try {
const captureResult = priorAuthCaptureTransaction(transactionId,
roundedAmount,
@@ -117,12 +145,19 @@ Meteor.methods({
return result;
},
- "authnet/refund/create": function () {
- Meteor.Error("Not Implemented", "Reaction does not currently support processing refunds through " +
- "Authorize.net for security reasons. Please see the README for more details");
+ "authnet/refund/create": function (paymentMethod, amount) {
+ check(paymentMethod, PaymentMethod);
+ check(amount, Number);
+ let result = {
+ saved: false,
+ error: "Reaction does not yet support direct refund processing from Authorize.net. " +
+ "
Please visit their web portal to perform this action. "
+ };
+
+ return result;
},
"authnet/refund/list": function () {
- Meteor.Error("Not Implemented", "Authorize.NET does not currently support getting a list of Refunds");
+ Meteor.Error("Not Implemented", "Authorize.net does not yet support retrieving a list of refunds.");
}
});
@@ -153,6 +188,18 @@ function priorAuthCaptureTransaction(transId, amount, service) {
return Promise.await(transactionRequest);
}
+function voidTransaction(transId, service) {
+ let body = {
+ transactionType: "voidTransaction",
+ refTransId: transId
+ };
+ // This call returns a Promise to the cb so we need to use Promise.await
+ let transactionRequest = service.sendTransactionRequest.call(service, body, function (trans) {
+ return trans;
+ });
+ return Promise.await(transactionRequest);
+}
+
ValidCardNumber = Match.Where(function (x) {
return /^[0-9]{14,16}$/.test(x);
});
@@ -168,4 +215,3 @@ ValidExpireYear = Match.Where(function (x) {
ValidCVV = Match.Where(function (x) {
return /^[0-9]{3,4}$/.test(x);
});
-
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/imports/plugins/included/example-paymentmethod/server/methods/example-payment-methods.app-test.js b/imports/plugins/included/example-paymentmethod/server/methods/example-payment-methods.app-test.js
index 3802f0911cb..2a6a5fcbe2b 100644
--- a/imports/plugins/included/example-paymentmethod/server/methods/example-payment-methods.app-test.js
+++ b/imports/plugins/included/example-paymentmethod/server/methods/example-payment-methods.app-test.js
@@ -72,6 +72,7 @@ describe("Submit payment", function () {
});
it("should call Example API with card and payment data", function () {
+ this.timeout(3000);
let cardData = {
name: "Test User",
number: "4242424242424242",
@@ -97,7 +98,6 @@ describe("Submit payment", function () {
cardData: cardData,
paymentData: paymentData
});
-
expect(results.saved).to.be.true;
});
diff --git a/imports/plugins/included/inventory/server/startup/hooks.js b/imports/plugins/included/inventory/server/hooks/hooks.js
similarity index 65%
rename from imports/plugins/included/inventory/server/startup/hooks.js
rename to imports/plugins/included/inventory/server/hooks/hooks.js
index e0114c7dc80..cd72e533933 100644
--- a/imports/plugins/included/inventory/server/startup/hooks.js
+++ b/imports/plugins/included/inventory/server/hooks/hooks.js
@@ -1,4 +1,4 @@
-import { Cart, Products } from "/lib/collections";
+import { Cart, Products, Orders } from "/lib/collections";
import { Logger } from "/server/api";
/**
@@ -89,3 +89,52 @@ Products.after.insert((userId, doc) => {
}
Meteor.call("inventory/register", doc);
});
+
+function markInventoryShipped(doc) {
+ const order = Orders.findOne(doc._id);
+ const orderItems = order.items;
+ let cartItems = [];
+ for (let orderItem of orderItems) {
+ let cartItem = {
+ _id: orderItem.cartItemId,
+ shopId: orderItem.shopId,
+ quantity: orderItem.quantity,
+ productId: orderItem.productId,
+ variants: orderItem.variants,
+ title: orderItem.title
+ };
+ cartItems.push(cartItem);
+ }
+ Meteor.call("inventory/shipped", cartItems);
+}
+
+function markInventorySold(doc) {
+ const orderItems = doc.items;
+ let cartItems = [];
+ for (let orderItem of orderItems) {
+ let cartItem = {
+ _id: orderItem.cartItemId,
+ shopId: orderItem.shopId,
+ quantity: orderItem.quantity,
+ productId: orderItem.productId,
+ variants: orderItem.variants,
+ title: orderItem.title
+ };
+ cartItems.push(cartItem);
+ }
+ Meteor.call("inventory/sold", cartItems);
+}
+
+Orders.after.insert((userId, doc) => {
+ Logger.debug("Inventory module handling Order insert");
+ markInventorySold(doc);
+});
+
+Orders.after.update((userId, doc, fieldnames, modifier) => {
+ Logger.debug("Inventory module handling Order update");
+ if (modifier.$addToSet) {
+ if (modifier.$addToSet["workflow.workflow"] === "coreOrderWorkflow/completed") {
+ markInventoryShipped(doc);
+ }
+ }
+});
diff --git a/imports/plugins/included/inventory/server/hooks/inventory-hooks.app-test.js b/imports/plugins/included/inventory/server/hooks/inventory-hooks.app-test.js
new file mode 100644
index 00000000000..39de0143d1f
--- /dev/null
+++ b/imports/plugins/included/inventory/server/hooks/inventory-hooks.app-test.js
@@ -0,0 +1,147 @@
+/* eslint dot-notation: 0 */
+import { Meteor } from "meteor/meteor";
+import { Inventory, Orders, Cart } from "/lib/collections";
+import { Reaction, Logger } from "/server/api";
+import { expect } from "meteor/practicalmeteor:chai";
+import { sinon } from "meteor/practicalmeteor:sinon";
+import Fixtures from "/server/imports/fixtures";
+import { getShop } from "/server/imports/fixtures/shops";
+
+Fixtures();
+
+function reduceCart(cart) {
+ Cart.update(cart._id, {
+ $set: {
+ "items.0.quantity": 1
+ }
+ });
+ Cart.update(cart._id, {
+ $set: {
+ "items.1.quantity": 1
+ }
+ });
+ Cart.update(cart._id, {
+ $pull: {
+ "items.$.quantity": {$gt: 1}
+ }
+ });
+}
+
+describe("Inventory Hooks", function () {
+ let originals;
+ let sandbox;
+
+ before(function () {
+ originals = {
+ mergeCart: Meteor.server.method_handlers["cart/mergeCart"],
+ createCart: Meteor.server.method_handlers["cart/createCart"],
+ copyCartToOrder: Meteor.server.method_handlers["cart/copyCartToOrder"],
+ addToCart: Meteor.server.method_handlers["cart/addToCart"],
+ setShipmentAddress: Meteor.server.method_handlers["cart/setShipmentAddress"],
+ setPaymentAddress: Meteor.server.method_handlers["cart/setPaymentAddress"]
+ };
+ });
+
+ beforeEach(function () {
+ sandbox = sinon.sandbox.create();
+ });
+
+ afterEach(function () {
+ sandbox.restore();
+ });
+
+ function spyOnMethod(method, id) {
+ return sandbox.stub(Meteor.server.method_handlers, `cart/${method}`, function () {
+ check(arguments, [Match.Any]); // to prevent audit_arguments from complaining
+ this.userId = id;
+ return originals[method].apply(this, arguments);
+ });
+ }
+
+ it("should move allocated inventory to 'sold' when an order is created", function () {
+ sandbox.stub(Meteor.server.method_handlers, "orders/sendNotification", function () {
+ check(arguments, [Match.Any]);
+ Logger.warn("running stub notification");
+ return true;
+ });
+ Inventory.direct.remove({});
+ const cart = Factory.create("cartToOrder");
+ reduceCart(cart);
+ sandbox.stub(Reaction, "getShopId", function () {
+ return cart.shopId;
+ });
+ let shop = getShop();
+ let product = cart.items[0];
+ const inventoryItem = Inventory.insert({
+ productId: product.productId,
+ variantId: product.variants._id,
+ shopId: shop._id,
+ workflow: {
+ status: "reserved"
+ },
+ orderItemId: product._id
+ });
+ expect(inventoryItem).to.not.be.undefined;
+ Inventory.update(inventoryItem._id,
+ {
+ $set: {
+ "workflow.status": "reserved",
+ "orderItemId": product._id
+ }
+ });
+ spyOnMethod("copyCartToOrder", cart.userId);
+ Meteor.call("cart/copyCartToOrder", cart._id);
+ let updatedInventoryItem = Inventory.findOne({
+ productId: product.productId,
+ variantId: product.variants._id,
+ shopId: shop._id,
+ orderItemId: product._id
+ });
+ expect(updatedInventoryItem.workflow.status).to.equal("sold");
+ });
+
+ it.skip("should move allocated inventory to 'shipped' when an order is shipped", function (done) {
+ this.timeout(5000);
+ sandbox.stub(Meteor.server.method_handlers, "orders/sendNotification", function () {
+ check(arguments, [Match.Any]);
+ Logger.warn("running stub notification");
+ return true;
+ });
+ sandbox.stub(Reaction, "hasPermission", () => true);
+ Inventory.direct.remove({});
+ const cart = Factory.create("cartToOrder");
+ reduceCart(cart);
+ sandbox.stub(Reaction, "getShopId", function () {
+ return cart.shopId;
+ });
+ let shop = getShop();
+ let product = cart.items[0];
+ const inventoryItem = Inventory.insert({
+ productId: product.productId,
+ variantId: product.variants._id,
+ shopId: shop._id,
+ workflow: {
+ status: "reserved"
+ },
+ orderItemId: product._id
+ });
+ expect(inventoryItem).to.not.be.undefined;
+ Inventory.update(inventoryItem._id,
+ {
+ $set: {
+ "workflow.status": "reserved",
+ "orderItemId": product._id
+ }
+ });
+ spyOnMethod("copyCartToOrder", cart.userId);
+ const orderId = Meteor.call("cart/copyCartToOrder", cart._id);
+ const order = Orders.findOne(orderId);
+ const shipping = { items: [] };
+ Meteor.call("orders/shipmentShipped", order, shipping, () => {
+ Meteor._sleepForMs(500);
+ const shippedInventoryItem = Inventory.findOne(inventoryItem._id);
+ expect(shippedInventoryItem.workflow.status).to.equal("shipped");
+ return done();
+ });
+ });
+});
diff --git a/imports/plugins/included/inventory/server/index.js b/imports/plugins/included/inventory/server/index.js
index c8b9bdaef6d..2c930662d85 100644
--- a/imports/plugins/included/inventory/server/index.js
+++ b/imports/plugins/included/inventory/server/index.js
@@ -1,8 +1,7 @@
+import "./methods/statusChanges";
import "./methods/inventory";
-import "./methods/inventory2";
import "./publications/inventory";
-import "./startup/hooks";
+import "./hooks/hooks";
import "./startup/init";
-
diff --git a/imports/plugins/included/inventory/server/methods/inventory.js b/imports/plugins/included/inventory/server/methods/inventory.js
index b6a2d94fb95..5752885d05f 100644
--- a/imports/plugins/included/inventory/server/methods/inventory.js
+++ b/imports/plugins/included/inventory/server/methods/inventory.js
@@ -1,278 +1,168 @@
import { Meteor } from "meteor/meteor";
+import { check, Match } from "meteor/check";
+import { Catalog } from "/lib/api";
import { Inventory } from "/lib/collections";
import * as Schemas from "/lib/collections/schemas";
import { Logger, Reaction } from "/server/api";
-// Disabled for now, needs more testing.
-// // Define a rate limiting rule that matches update attempts by non-admin users
-// const addReserveRule = {
-// userId: function (userId) {
-// return Roles.userIsInRole(userId, "createProduct", Reaction.getShopId());
-// },
-// type: "subscription",
-// method: "Inventory"
-// };
-//
-// // Define a rate limiting rule that matches backorder attempts by non-admin users
-// const addBackorderRule = {
-// userId: function (userId) {
-// return Roles.userIsInRole(userId, "createProduct", Reaction.getShopId());
-// },
-// type: "method",
-// method: "inventory/backorder"
-// };
-//
-// // Add the rule, allowing up to 5 messages every 1000 milliseconds.
-// DDPRateLimiter.addRule(addReserveRule, 5, 1000);
-// DDPRateLimiter.addRule(addBackorderRule, 5, 1000);
-
-//
-// Inventory methods
-//
-
-Meteor.methods({
- /**
- * inventory/setStatus
- * @summary sets status from one status to a new status. Defaults to "new" to "reserved"
- * @param {Array} cartItems array of objects of type Schemas.CartItems
- * @param {String} status optional - sets the inventory workflow status, defaults to "reserved"
- * @todo move this to bulkOp
- * @return {undefined} returns undefined
- */
- "inventory/setStatus": function (cartItems, status, currentStatus, notFoundStatus) {
- check(cartItems, [Schemas.CartItem]);
- check(status, Match.Optional(String));
- check(currentStatus, Match.Optional(String));
- check(notFoundStatus, Match.Optional(String));
- this.unblock();
-
- // check basic user permissions
- // if (!Reaction.hasPermission(["guest", "anonymous"])) {
- // throw new Meteor.Error(403, "Access Denied");
- // }
-
- // set defaults
- const reservationStatus = status || "reserved"; // change status to options object
- const defaultStatus = currentStatus || "new"; // default to the "new" status
- const backorderStatus = notFoundStatus || "backorder"; // change status to options object
- let reservationCount = 0;
-
- // update inventory status for cartItems
- for (let item of cartItems) {
- // check of existing reserved inventory for this cart
- let existingReservations = Inventory.find({
- productId: item.productId,
- variantId: item.variants._id,
- shopId: item.shopId,
- orderItemId: item._id
- });
-
- // define a new reservation
- let availableInventory = Inventory.find({
- "productId": item.productId,
- "variantId": item.variants._id,
- "shopId": item.shopId,
- "workflow.status": defaultStatus
- });
-
- const totalRequiredQty = item.quantity;
- const availableInventoryQty = availableInventory.count();
- let existingReservationQty = existingReservations.count();
-
- Logger.info("totalRequiredQty", totalRequiredQty);
- Logger.info("availableInventoryQty", availableInventoryQty);
-
- // if we don't have existing inventory we create backorders
- if (totalRequiredQty > availableInventoryQty) {
- // TODO put in a dashboard setting to allow backorder or altenate handler to be used
- let backOrderQty = Number(totalRequiredQty - availableInventoryQty - existingReservationQty);
- Logger.info(`no inventory found, create ${backOrderQty} ${backorderStatus}`);
- // define a new reservation
- const reservation = {
- productId: item.productId,
- variantId: item.variants._id,
- shopId: item.shopId,
- orderItemId: item._id,
- workflow: {
- status: backorderStatus
- }
- };
-
- Meteor.call("inventory/backorder", reservation, backOrderQty);
- existingReservationQty = backOrderQty;
- }
- // if we have inventory available, only create additional required reservations
- Logger.debug("existingReservationQty", existingReservationQty);
- reservationCount = existingReservationQty;
- let newReservedQty = totalRequiredQty - existingReservationQty + 1;
- let i = 1;
-
- while (i < newReservedQty) {
- // updated existing new inventory to be reserved
- Logger.info(
- `updating reservation status ${i} of ${newReservedQty - 1}/${totalRequiredQty} items.`);
- // we should be updating existing inventory here.
- // backorder process created additional backorder inventory if there
- // wasn't enough.
- Inventory.update({
- "productId": item.productId,
- "variantId": item.variants._id,
- "shopId": item.shopId,
- "workflow.status": "new"
- }, {
- $set: {
- "orderItemId": item._id,
- "workflow.status": reservationStatus
+export function registerInventory(product) {
+ check(product, Match.OneOf(Schemas.ProductVariant, Schemas.Product));
+ let type;
+ switch (product.type) {
+ case "variant":
+ check(product, Schemas.ProductVariant);
+ type = "variant";
+ break;
+ default:
+ check(product, Schemas.Product);
+ type = "simple";
+ }
+ let totalNewInventory = 0;
+ const productId = type === "variant" ? product.ancestors[0] : product._id;
+ const variants = Catalog.getVariants(productId);
+
+ // we'll check each variant to see if it has been fully registered
+ for (let variant of variants) {
+ let inventory = Inventory.find({
+ productId: productId,
+ variantId: variant._id,
+ shopId: product.shopId
+ });
+ // we'll return this as well
+ let inventoryVariantCount = inventory.count();
+ // if the variant exists already we're remove from the inventoryVariants
+ // so that we don't process it as an insert
+ if (inventoryVariantCount < variant.inventoryQuantity) {
+ let newQty = variant.inventoryQuantity || 0;
+ let i = inventoryVariantCount + 1;
+
+ Logger.info(
+ `inserting ${newQty - inventoryVariantCount
+ } new inventory items for ${variant._id}`
+ );
+
+ const batch = Inventory.
+ _collection.rawCollection().initializeUnorderedBulkOp();
+ while (i <= newQty) {
+ let id = Inventory._makeNewID();
+ batch.insert({
+ _id: id,
+ productId: productId,
+ variantId: variant._id,
+ shopId: product.shopId,
+ createdAt: new Date,
+ updatedAt: new Date,
+ workflow: { // we add this line because `batchInsert` doesn't know
+ status: "new" // about SimpleSchema, so `defaultValue` will not
}
});
- reservationCount++;
i++;
}
- }
- Logger.info(
- `finished creating ${reservationCount} new ${reservationStatus} reservations`);
- return reservationCount;
- },
- /**
- * inventory/clearStatus
- * @summary used to reset status on inventory item (defaults to "new")
- * @param {Array} cartItems array of objects Schemas.CartItem
- * @param {[type]} status optional reset workflow.status, defaults to "new"
- * @param {[type]} currentStatus optional matching workflow.status, defaults to "reserved"
- * @return {undefined} undefined
- */
- "inventory/clearStatus": function (cartItems, status, currentStatus) {
- check(cartItems, [Schemas.CartItem]);
- check(status, Match.Optional(String)); // workflow status
- check(currentStatus, Match.Optional(String));
- this.unblock();
-
- // // check basic user permissions
- // if (!Reaction.hasPermission(["guest", "anonymous"])) {
- // throw new Meteor.Error(403, "Access Denied");
- // }
- // optional workflow status or default to "new"
- let newStatus = status || "new";
- let oldStatus = currentStatus || "reserved";
+ // took from: http://guide.meteor.com/collections.html#bulk-data-changes
+ let execute = Meteor.wrapAsync(batch.execute, batch);
+ let inventoryItem = execute();
+ let inserted = inventoryItem.nInserted;
- // remove each cart item in inventory
- for (let item of cartItems) {
- // check of existing reserved inventory for this cart
- let existingReservations = Inventory.find({
- "productId": item.productId,
- "variantId": item.variants._id,
- "shopId": item.shopId,
- "orderItemId": item._id,
- "workflow.status": oldStatus
- });
- let i = existingReservations.count();
- // reset existing cartItem reservations
- while (i <= item.quantity) {
- Inventory.update({
- "productId": item.productId,
- "variantId": item.variants._id,
- "shopId": item.shopId,
- "orderItemId": item._id,
- "workflow.status": oldStatus
- }, {
- $set: {
- "orderItemId": "", // clear order/cart
- "workflow.status": newStatus // reset status
- }
- });
- i++;
+ if (!inserted) { // or maybe `inventory.length === 0`?
+ // throw new Meteor.Error("Inventory Anomaly Detected. Abort! Abort!");
+ return totalNewInventory;
}
+ Logger.debug(`registered ${inserted}`);
+ totalNewInventory += inserted;
}
- Logger.info("inventory/clearReserve", newStatus);
- },
- /**
- * inventory/clearReserve
- * @summary resets "reserved" items to "new"
- * @param {Array} cartItems array of objects Schemas.CartItem
- * @return {undefined}
- */
- "inventory/clearReserve": function (cartItems) {
- check(cartItems, [Schemas.CartItem]);
- return Meteor.call("inventory/clearStatus", cartItems);
- },
+ }
+ // returns the total amount of new inventory created
+ return totalNewInventory;
+}
+
+Meteor.methods({
/**
- * inventory/clearReserve
- * converts new items to reserved, or backorders
- * @param {Array} cartItems array of objects Schemas.CartItem
- * @return {undefined}
+ * inventory/register
+ * @summary check a product and update Inventory collection with inventory documents.
+ * @param {Object} product - valid Schemas.Product object
+ * @return {Number} - returns the total amount of new inventory created
*/
- "inventory/addReserve": function (cartItems) {
- check(cartItems, [Schemas.CartItem]);
- return Meteor.call("inventory/setStatus", cartItems);
+ "inventory/register": function (product) {
+ if (!Reaction.hasPermission("createProduct")) {
+ throw new Meteor.Error(403, "Access Denied");
+ }
+ registerInventory(product);
},
/**
- * inventory/backorder
- * @summary is used by the cart process to create a new Inventory
- * backorder item, but this could be used for inserting any
- * custom inventory.
- *
- * A note on DDP Limits.
- * As these are wide open we defined some ddp limiting rules http://docs.meteor.com/#/full/ddpratelimiter
+ * inventory/adjust
+ * @summary adjust existing inventory when changes are made we get the
+ * inventoryQuantity for each product variant, and compare the qty to the qty
+ * in the inventory records we will add inventoryItems as needed to have the
+ * same amount as the inventoryQuantity but when deleting, we'll refuse to
+ * delete anything not workflow.status = "new"
*
- * @param {Object} reservation Schemas.Inventory
- * @param {Number} backOrderQty number of backorder items to create
- * @returns {Number} number of inserted backorder documents
+ * @param {Object} product - Schemas.Product object
+ * @return {[undefined]} returns undefined
+ * @todo should be variant
*/
- "inventory/backorder": function (reservation, backOrderQty) {
- check(reservation, Schemas.Inventory);
- check(backOrderQty, Number);
- this.unblock();
-
- // this use case could happen then mergeCart is fires. We don't add anything
- // or remove, just item owner changed. We need to add this check here
- // because of bulk operation. It thows exception if nothing to operate.
- if (backOrderQty === 0) {
- return 0;
+ "inventory/adjust": function (product) {
+ check(product, Match.OneOf(Schemas.Product, Schemas.ProductVariant));
+ let type;
+ let results;
+ // adds or updates inventory collection with this product
+ switch (product.type) {
+ case "variant":
+ check(product, Schemas.ProductVariant);
+ type = "variant";
+ break;
+ default:
+ check(product, Schemas.Product);
+ type = "simple";
}
+ // user needs createProduct permission to adjust inventory
+ if (!Reaction.hasPermission("createProduct")) {
+ throw new Meteor.Error(403, "Access Denied");
+ }
+ // this.unblock();
- // TODO: need to look carefully and understand is it possible ho have a
- // negative `backOrderQty` value here?
-
- // check basic user permissions
- // if (!Reaction.hasPermission(["guest","anonymous"])) {
- // throw new Meteor.Error(403, "Access Denied");
- // }
-
- // set defaults
- let newReservation = reservation;
- if (!newReservation.workflow) {
- newReservation.workflow = {
- status: "backorder"
+ // Quantity and variants of this product's variant inventory
+ if (type === "variant") {
+ const variant = {
+ _id: product._id,
+ qty: product.inventoryQuantity || 0
};
- }
- // insert backorder
- let i = 0;
- const batch = Inventory.
- _collection.rawCollection().initializeUnorderedBulkOp();
- while (i < backOrderQty) {
- let id = Inventory._makeNewID();
- batch.insert(Object.assign({ _id: id }, newReservation));
- i++;
+ const inventory = Inventory.find({
+ productId: product.ancestors[0],
+ variantId: product._id
+ });
+ const itemCount = inventory.count();
+
+ if (itemCount !== variant.qty) {
+ if (itemCount < variant.qty) {
+ // we need to register some new variants to inventory
+ results = itemCount + Meteor.call("inventory/register", product);
+ } else if (itemCount > variant.qty) {
+ // determine how many records to delete
+ const removeQty = itemCount - variant.qty;
+ // we're only going to delete records that are new
+ const removeInventory = Inventory.find({
+ "variantId": variant._id,
+ "workflow.status": "new"
+ }, {
+ sort: {
+ updatedAt: -1
+ },
+ limit: removeQty
+ }).fetch();
+
+ results = itemCount;
+ // delete latest inventory "status:new" records
+ for (let inventoryItem of removeInventory) {
+ results -= Meteor.call("inventory/remove", inventoryItem);
+ // we could add handling for the case when aren't enough "new" items
+ }
+ Logger.info(`adjust variant ${variant._id} from ${itemCount} to ${results}`);
+ }
+ }
}
-
- const execute = Meteor.wrapAsync(batch.execute, batch);
- const inventoryBackorder = execute();
- const inserted = inventoryBackorder.nInserted;
- Logger.info(
- `created ${inserted} backorder records for product ${
- newReservation.productId}, variant ${newReservation.variantId}`);
-
- return inserted;
- },
- //
- // send low stock warnings
- //
- "inventory/lowStock": function (product) {
- check(product, Schemas.Product);
- // WIP placeholder
- Logger.info("inventory/lowStock");
}
+
});
diff --git a/imports/plugins/included/inventory/server/methods/inventory2.js b/imports/plugins/included/inventory/server/methods/inventory2.js
deleted file mode 100644
index 10f8b38133b..00000000000
--- a/imports/plugins/included/inventory/server/methods/inventory2.js
+++ /dev/null
@@ -1,217 +0,0 @@
-import { Meteor } from "meteor/meteor";
-import { Catalog } from "/lib/api";
-import { Inventory } from "/lib/collections";
-import * as Schemas from "/lib/collections/schemas";
-import { Logger, Reaction } from "/server/api";
-
-//
-// Inventory methods
-//
-
-export function registerInventory(product) {
- let type;
- switch (product.type) {
- case "variant":
- check(product, Schemas.ProductVariant);
- type = "variant";
- break;
- default:
- check(product, Schemas.Product);
- type = "simple";
- }
- let totalNewInventory = 0;
- const productId = type === "variant" ? product.ancestors[0] : product._id;
- const variants = Catalog.getVariants(productId);
-
- // we'll check each variant to see if it has been fully registered
- for (let variant of variants) {
- let inventory = Inventory.find({
- productId: productId,
- variantId: variant._id,
- shopId: product.shopId
- });
- // we'll return this as well
- let inventoryVariantCount = inventory.count();
- // if the variant exists already we're remove from the inventoryVariants
- // so that we don't process it as an insert
- if (inventoryVariantCount < variant.inventoryQuantity) {
- let newQty = variant.inventoryQuantity || 0;
- let i = inventoryVariantCount + 1;
-
- Logger.info(
- `inserting ${newQty - inventoryVariantCount
- } new inventory items for ${variant._id}`
- );
-
- const batch = Inventory.
- _collection.rawCollection().initializeUnorderedBulkOp();
- while (i <= newQty) {
- let id = Inventory._makeNewID();
- batch.insert({
- _id: id,
- productId: productId,
- variantId: variant._id,
- shopId: product.shopId,
- createdAt: new Date,
- updatedAt: new Date,
- workflow: { // we add this line because `batchInsert` doesn't know
- status: "new" // about SimpleSchema, so `defaultValue` will not
- }
- });
- i++;
- }
-
- // took from: http://guide.meteor.com/collections.html#bulk-data-changes
- let execute = Meteor.wrapAsync(batch.execute, batch);
- let inventoryItem = execute();
- let inserted = inventoryItem.nInserted;
-
- if (!inserted) { // or maybe `inventory.length === 0`?
- // throw new Meteor.Error("Inventory Anomaly Detected. Abort! Abort!");
- return totalNewInventory;
- }
- Logger.debug(`registered ${inserted}`);
- totalNewInventory += inserted;
- }
- }
- // returns the total amount of new inventory created
- return totalNewInventory;
-}
-
-Meteor.methods({
- /**
- * inventory/register
- * @summary check a product and update Inventory collection with inventory documents.
- * @param {Object} product - valid Schemas.Product object
- * @return {Number} - returns the total amount of new inventory created
- */
- "inventory/register": function (product) {
- if (!Reaction.hasPermission("createProduct")) {
- throw new Meteor.Error(403, "Access Denied");
- }
- registerInventory(product);
- },
- /**
- * inventory/adjust
- * @summary adjust existing inventory when changes are made we get the
- * inventoryQuantity for each product variant, and compare the qty to the qty
- * in the inventory records we will add inventoryItems as needed to have the
- * same amount as the inventoryQuantity but when deleting, we'll refuse to
- * delete anything not workflow.status = "new"
- *
- * @param {Object} product - Schemas.Product object
- * @return {[undefined]} returns undefined
- */
- "inventory/adjust": function (product) { // TODO: this should be variant
- let type;
- let results;
- // adds or updates inventory collection with this product
- switch (product.type) {
- case "variant":
- check(product, Schemas.ProductVariant);
- type = "variant";
- break;
- default:
- check(product, Schemas.Product);
- type = "simple";
- }
- // user needs createProduct permission to adjust inventory
- if (!Reaction.hasPermission("createProduct")) {
- throw new Meteor.Error(403, "Access Denied");
- }
- // this.unblock();
-
- // Quantity and variants of this product's variant inventory
- if (type === "variant") {
- const variant = {
- _id: product._id,
- qty: product.inventoryQuantity || 0
- };
-
- const inventory = Inventory.find({
- productId: product.ancestors[0],
- variantId: product._id
- });
- const itemCount = inventory.count();
-
- if (itemCount !== variant.qty) {
- if (itemCount < variant.qty) {
- // we need to register some new variants to inventory
- results = itemCount + Meteor.call("inventory/register", product);
- } else if (itemCount > variant.qty) {
- // determine how many records to delete
- const removeQty = itemCount - variant.qty;
- // we're only going to delete records that are new
- const removeInventory = Inventory.find({
- "variantId": variant._id,
- "workflow.status": "new"
- }, {
- sort: {
- updatedAt: -1
- },
- limit: removeQty
- }).fetch();
-
- results = itemCount;
- // delete latest inventory "status:new" records
- for (let inventoryItem of removeInventory) {
- results -= Meteor.call("inventory/remove", inventoryItem);
- // we could add handling for the case when aren't enough "new" items
- }
- }
- Logger.info(
- `adjust variant ${variant._id} from ${itemCount} to ${results}`
- );
- }
- }
- },
- /**
- * inventory/remove
- * delete an inventory item permanently
- * @param {Object} inventoryItem object type Schemas.Inventory
- * @return {String} return remove result
- */
- "inventory/remove": function (inventoryItem) {
- check(inventoryItem, Schemas.Inventory);
- // user needs createProduct permission to adjust inventory
- if (!Reaction.hasPermission("createProduct")) {
- throw new Meteor.Error(403, "Access Denied");
- }
- // this.unblock();
- // todo add bulkOp here
-
- Logger.debug("inventory/remove", inventoryItem);
- return Inventory.remove(inventoryItem);
- },
- /**
- * inventory/shipped
- * mark inventory as shipped
- * @param {Array} cartItems array of objects Schemas.CartItem
- * @return {undefined}
- */
- "inventory/shipped": function (cartItems) {
- check(cartItems, [Schemas.CartItem]);
- return Meteor.call("inventory/setStatus", cartItems, "shipped");
- },
- /**
- * inventory/shipped
- * mark inventory as returned
- * @param {Array} cartItems array of objects Schemas.CartItem
- * @return {undefined}
- */
- "inventory/return": function (cartItems) {
- check(cartItems, [Schemas.CartItem]);
- return Meteor.call("inventory/setStatus", cartItems, "return");
- },
- /**
- * inventory/shipped
- * mark inventory as return and available for sale
- * @param {Array} cartItems array of objects Schemas.CartItem
- * @return {undefined}
- */
- "inventory/returnToStock": function (productId, variantId) {
- check(productId, String);
- check(variantId, String);
- return Meteor.call("inventory/clearStatus", cartItems, "new", "return");
- }
-});
diff --git a/imports/plugins/included/inventory/server/methods/statusChanges.js b/imports/plugins/included/inventory/server/methods/statusChanges.js
new file mode 100644
index 00000000000..eba446ec1af
--- /dev/null
+++ b/imports/plugins/included/inventory/server/methods/statusChanges.js
@@ -0,0 +1,346 @@
+import { Meteor } from "meteor/meteor";
+import { check, Match } from "meteor/check";
+import { Inventory } from "/lib/collections";
+import * as Schemas from "/lib/collections/schemas";
+import { Logger, Reaction } from "/server/api";
+
+// Disabled for now, needs more testing.
+
+// // Define a rate limiting rule that matches update attempts by non-admin users
+// const addReserveRule = {
+// userId: function (userId) {
+// return Roles.userIsInRole(userId, "createProduct", Reaction.getShopId());
+// },
+// type: "subscription",
+// method: "Inventory"
+// };
+//
+// // Define a rate limiting rule that matches backorder attempts by non-admin users
+// const addBackorderRule = {
+// userId: function (userId) {
+// return Roles.userIsInRole(userId, "createProduct", Reaction.getShopId());
+// },
+// type: "method",
+// method: "inventory/backorder"
+// };
+//
+// // Add the rule, allowing up to 5 messages every 1000 milliseconds.
+// DDPRateLimiter.addRule(addReserveRule, 5, 1000);
+// DDPRateLimiter.addRule(addBackorderRule, 5, 1000);
+
+//
+// Inventory methods
+//
+
+Meteor.methods({
+ /**
+ * inventory/setStatus
+ * @summary sets status from one status to a new status. Defaults to "new" to "reserved"
+ * @param {Array} cartItems array of objects of type Schemas.CartItems
+ * @param {String} status optional - sets the inventory workflow status, defaults to "reserved"
+ * @param {String} currentStatus - what is the current status to change "from"
+ * @param {String} notFoundStatus - what to use if the status is not found
+ * @todo move this to bulkOp
+ * @return {Number} returns reservationCount
+ */
+ "inventory/setStatus": function (cartItems, status, currentStatus, notFoundStatus) {
+ check(cartItems, [Schemas.CartItem]);
+ check(status, Match.Optional(String));
+ check(currentStatus, Match.Optional(String));
+ check(notFoundStatus, Match.Optional(String));
+ this.unblock();
+
+ // check basic user permissions
+ // if (!Reaction.hasPermission(["guest", "anonymous"])) {
+ // throw new Meteor.Error(403, "Access Denied");
+ // }
+
+ // set defaults
+ const reservationStatus = status || "reserved"; // change status to options object
+ const defaultStatus = currentStatus || "new"; // default to the "new" status
+ const backorderStatus = notFoundStatus || "backorder"; // change status to options object
+ let reservationCount;
+ Logger.info(`Moving Inventory items from ${defaultStatus} to ${reservationStatus}`);
+
+ // update inventory status for cartItems
+ for (let item of cartItems) {
+ // check of existing reserved inventory for this cart
+ let existingReservations = Inventory.find({
+ productId: item.productId,
+ variantId: item.variants._id,
+ shopId: item.shopId,
+ orderItemId: item._id
+ });
+
+ // define a new reservation
+ let availableInventory = Inventory.find({
+ "productId": item.productId,
+ "variantId": item.variants._id,
+ "shopId": item.shopId,
+ "workflow.status": defaultStatus
+ });
+
+ const totalRequiredQty = item.quantity;
+ const availableInventoryQty = availableInventory.count();
+ let existingReservationQty = existingReservations.count();
+
+ Logger.info("totalRequiredQty", totalRequiredQty);
+ Logger.info("availableInventoryQty", availableInventoryQty);
+
+ // if we don't have existing inventory we create backorders
+ if (totalRequiredQty > availableInventoryQty) {
+ // TODO put in a dashboard setting to allow backorder or altenate handler to be used
+ let backOrderQty = Number(totalRequiredQty - availableInventoryQty - existingReservationQty);
+ Logger.info(`no inventory found, create ${backOrderQty} ${backorderStatus}`);
+ // define a new reservation
+ const reservation = {
+ productId: item.productId,
+ variantId: item.variants._id,
+ shopId: item.shopId,
+ orderItemId: item._id,
+ workflow: {
+ status: backorderStatus
+ }
+ };
+
+ Meteor.call("inventory/backorder", reservation, backOrderQty);
+ existingReservationQty = backOrderQty;
+ }
+ // if we have inventory available, only create additional required reservations
+ Logger.debug("existingReservationQty", existingReservationQty);
+ reservationCount = existingReservationQty;
+ let newReservedQty;
+ if (reservationStatus === "reserved" && defaultStatus === "new") {
+ newReservedQty = totalRequiredQty - existingReservationQty + 1;
+ } else {
+ // when moving from one "reserved" type status, we don't need to deal with existingReservationQty
+ newReservedQty = totalRequiredQty + 1;
+ }
+
+ let i = 1;
+ while (i < newReservedQty) {
+ // updated existing new inventory to be reserved
+ Logger.info(
+ `updating reservation status ${i} of ${newReservedQty - 1}/${totalRequiredQty} items.`);
+ // we should be updating existing inventory here.
+ // backorder process created additional backorder inventory if there
+ // wasn't enough.
+ Inventory.update({
+ "productId": item.productId,
+ "variantId": item.variants._id,
+ "shopId": item.shopId,
+ "workflow.status": defaultStatus
+ }, {
+ $set: {
+ "orderItemId": item._id,
+ "workflow.status": reservationStatus
+ }
+ });
+ reservationCount++;
+ i++;
+ }
+ }
+ Logger.info(
+ `finished creating ${reservationCount} new ${reservationStatus} reservations`);
+ return reservationCount;
+ },
+ /**
+ * inventory/clearStatus
+ * @summary used to reset status on inventory item (defaults to "new")
+ * @param {Array} cartItems array of objects Schemas.CartItem
+ * @param {[type]} status optional reset workflow.status, defaults to "new"
+ * @param {[type]} currentStatus optional matching workflow.status, defaults to "reserved"
+ * @return {undefined} undefined
+ */
+ "inventory/clearStatus": function (cartItems, status, currentStatus) {
+ check(cartItems, [Schemas.CartItem]);
+ check(status, Match.Optional(String)); // workflow status
+ check(currentStatus, Match.Optional(String));
+ this.unblock();
+
+ // // check basic user permissions
+ // if (!Reaction.hasPermission(["guest", "anonymous"])) {
+ // throw new Meteor.Error(403, "Access Denied");
+ // }
+
+ // optional workflow status or default to "new"
+ let newStatus = status || "new";
+ let oldStatus = currentStatus || "reserved";
+
+ // remove each cart item in inventory
+ for (let item of cartItems) {
+ // check of existing reserved inventory for this cart
+ let existingReservations = Inventory.find({
+ "productId": item.productId,
+ "variantId": item.variants._id,
+ "shopId": item.shopId,
+ "orderItemId": item._id,
+ "workflow.status": oldStatus
+ });
+ let i = existingReservations.count();
+ // reset existing cartItem reservations
+ while (i <= item.quantity) {
+ Inventory.update({
+ "productId": item.productId,
+ "variantId": item.variants._id,
+ "shopId": item.shopId,
+ "orderItemId": item._id,
+ "workflow.status": oldStatus
+ }, {
+ $set: {
+ "orderItemId": "", // clear order/cart
+ "workflow.status": newStatus // reset status
+ }
+ });
+ i++;
+ }
+ }
+ Logger.info("inventory/clearReserve", newStatus);
+ },
+ /**
+ * inventory/clearReserve
+ * @summary resets "reserved" items to "new"
+ * @param {Array} cartItems array of objects Schemas.CartItem
+ * @return {undefined}
+ */
+ "inventory/clearReserve": function (cartItems) {
+ check(cartItems, [Schemas.CartItem]);
+ return Meteor.call("inventory/clearStatus", cartItems);
+ },
+ /**
+ * inventory/clearReserve
+ * converts new items to reserved, or backorders
+ * @param {Array} cartItems array of objects Schemas.CartItem
+ * @return {undefined}
+ */
+ "inventory/addReserve": function (cartItems) {
+ check(cartItems, [Schemas.CartItem]);
+ return Meteor.call("inventory/setStatus", cartItems);
+ },
+ /**
+ * inventory/backorder
+ * @summary is used by the cart process to create a new Inventory
+ * backorder item, but this could be used for inserting any
+ * custom inventory.
+ *
+ * A note on DDP Limits.
+ * As these are wide open we defined some ddp limiting rules http://docs.meteor.com/#/full/ddpratelimiter
+ *
+ * @param {Object} reservation Schemas.Inventory
+ * @param {Number} backOrderQty number of backorder items to create
+ * @returns {Number} number of inserted backorder documents
+ */
+ "inventory/backorder": function (reservation, backOrderQty) {
+ check(reservation, Schemas.Inventory);
+ check(backOrderQty, Number);
+ this.unblock();
+
+ // this use case could happen then mergeCart is fires. We don't add anything
+ // or remove, just item owner changed. We need to add this check here
+ // because of bulk operation. It thows exception if nothing to operate.
+ if (backOrderQty === 0) {
+ return 0;
+ }
+
+ // TODO: need to look carefully and understand is it possible ho have a
+ // negative `backOrderQty` value here?
+
+ // check basic user permissions
+ // if (!Reaction.hasPermission(["guest","anonymous"])) {
+ // throw new Meteor.Error(403, "Access Denied");
+ // }
+
+ // set defaults
+ let newReservation = reservation;
+ if (!newReservation.workflow) {
+ newReservation.workflow = {
+ status: "backorder"
+ };
+ }
+
+ // insert backorder
+ let i = 0;
+ const batch = Inventory.
+ _collection.rawCollection().initializeUnorderedBulkOp();
+ while (i < backOrderQty) {
+ let id = Inventory._makeNewID();
+ batch.insert(Object.assign({ _id: id }, newReservation));
+ i++;
+ }
+
+ const execute = Meteor.wrapAsync(batch.execute, batch);
+ const inventoryBackorder = execute();
+ const inserted = inventoryBackorder.nInserted;
+ Logger.info(
+ `created ${inserted} backorder records for product ${
+ newReservation.productId}, variant ${newReservation.variantId}`);
+
+ return inserted;
+ },
+ //
+ // send low stock warnings
+ //
+ "inventory/lowStock": function (product) {
+ check(product, Schemas.Product);
+ // WIP placeholder
+ Logger.info("inventory/lowStock");
+ },
+ /**
+ * inventory/remove
+ * delete an inventory item permanently
+ * @param {Object} inventoryItem object type Schemas.Inventory
+ * @return {String} return remove result
+ */
+ "inventory/remove": function (inventoryItem) {
+ check(inventoryItem, Schemas.Inventory);
+ // user needs createProduct permission to adjust inventory
+ if (!Reaction.hasPermission("createProduct")) {
+ throw new Meteor.Error(403, "Access Denied");
+ }
+ // this.unblock();
+ // todo add bulkOp here
+
+ Logger.debug("inventory/remove", inventoryItem);
+ return Inventory.remove(inventoryItem);
+ },
+ /**
+ * inventory/shipped
+ * mark inventory as shipped
+ * @param {Array} cartItems array of objects Schemas.CartItem
+ * @return {undefined}
+ */
+ "inventory/shipped": function (cartItems) {
+ check(cartItems, [Schemas.CartItem]);
+ return Meteor.call("inventory/setStatus", cartItems, "shipped", "sold");
+ },
+ /**
+ * inventory/sold
+ * mark inventory as sold
+ * @param {Array} cartItems array of objects Schemas.CartItem
+ * @return {undefined}
+ */
+ "inventory/sold": function (cartItems) {
+ check(cartItems, [Schemas.CartItem]);
+ return Meteor.call("inventory/setStatus", cartItems, "sold", "reserved");
+ },
+ /**
+ * inventory/return
+ * mark inventory as returned
+ * @param {Array} cartItems array of objects Schemas.CartItem
+ * @return {undefined}
+ */
+ "inventory/return": function (cartItems) {
+ check(cartItems, [Schemas.CartItem]);
+ return Meteor.call("inventory/setStatus", cartItems, "return");
+ },
+ /**
+ * inventory/returnToStock
+ * mark inventory as return and available for sale
+ * @param {Array} cartItems array of objects Schemas.CartItem
+ * @return {undefined}
+ */
+ "inventory/returnToStock": function (cartItems) {
+ check(cartItems, [Schemas.CartItem]);
+ return Meteor.call("inventory/clearStatus", cartItems, "new", "return");
+ }
+});
diff --git a/imports/plugins/included/inventory/server/startup/init.js b/imports/plugins/included/inventory/server/startup/init.js
index 31b4f33a994..f6ed711bf4a 100644
--- a/imports/plugins/included/inventory/server/startup/init.js
+++ b/imports/plugins/included/inventory/server/startup/init.js
@@ -1,6 +1,6 @@
import { Hooks, Logger } from "/server/api";
import { Products, Inventory } from "/lib/collections";
-import { registerInventory } from "../methods/inventory2";
+import { registerInventory } from "../methods/inventory";
// On first-time startup init the Inventory collection with entries for each product
Hooks.Events.add("afterCoreInit", () => {
diff --git a/imports/plugins/included/launchdock-connect/client/index.js b/imports/plugins/included/launchdock-connect/client/index.js
new file mode 100644
index 00000000000..21dda4283da
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/client/index.js
@@ -0,0 +1,9 @@
+import "../lib/collections";
+
+import "./templates/connect.html";
+import "./templates/connect.js";
+import "./templates/dashboard.html";
+import "./templates/dashboard.less";
+import "./templates/dashboard.js";
+import "./templates/settings.html";
+import "./templates/settings.js";
diff --git a/imports/plugins/included/launchdock-connect/client/templates/connect.html b/imports/plugins/included/launchdock-connect/client/templates/connect.html
new file mode 100644
index 00000000000..d9c1993d12c
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/client/templates/connect.html
@@ -0,0 +1,53 @@
+
+ {{> Template.dynamic template="dashboardHeader"}}
+ {{#if Template.subscriptionsReady}}
+ {{#if ldConnection}}
+ {{> launchdockDashboard}}
+ {{else}}
+ {{> reactionConnectDashboard}}
+ {{/if}}
+ {{/if}}
+
+
+
+ {{#if Template.subscriptionsReady}}
+ {{#if ldConnection}}
+ {{> launchdockSettings}}
+ {{else}}
+ {{> reactionConnectSettings}}
+ {{/if}}
+ {{/if}}
+
+
+
+
+
+
+
+
+
Reaction, Meteor, and MongoDB bundled for production and deployed as a stack of Docker containers in a scalable private cluster!
+
Launchdock is a utility from the Reaction Core team, that manages this deployment.
+
+ Support for your custom SSL certificates
+ Support for your custom domains
+ Private scaling Application and DB cluster
+
+
Go ahead, get connected with Reaction
+
+
+
+
+
+
+
+
+
+
+
diff --git a/imports/plugins/included/launchdock-connect/client/templates/connect.js b/imports/plugins/included/launchdock-connect/client/templates/connect.js
new file mode 100644
index 00000000000..da6cefee8bb
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/client/templates/connect.js
@@ -0,0 +1,29 @@
+import { Template } from "meteor/templating";
+import Launchdock from "../../lib/launchdock";
+
+/**
+ * Checks to see if we have a valid connection to ld,
+ * and currently assumes you don't have a launchdock account
+ * rather than being some kind of status indicator (really should be both)
+ */
+
+Template.connectDashboard.onCreated(function () {
+ this.subscribe("launchdock-auth");
+});
+
+Template.connectDashboard.helpers({
+ ldConnection() {
+ return Launchdock.connect();
+ }
+});
+
+
+Template.connectSettings.onCreated(function () {
+ this.subscribe("launchdock-auth");
+});
+
+Template.connectSettings.helpers({
+ ldConnection() {
+ return Launchdock.connect();
+ }
+});
diff --git a/imports/plugins/included/launchdock-connect/client/templates/dashboard.html b/imports/plugins/included/launchdock-connect/client/templates/dashboard.html
new file mode 100644
index 00000000000..56a9fe5038f
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/client/templates/dashboard.html
@@ -0,0 +1,112 @@
+
+
+ {{#with packageData}}
+
+
+ {{#if launchdockDataReady}}
+
+
+
+
Account Status
+
+
+ {{#if isSubscribed}}
+
+
Current Plan:
+
{{plan}} Subscription
+
+
+
Next Payment Due:
+
{{nextPayment}}
+
+ {{else}}
+
+ Free trial ends: {{trial.ends}} ({{trial.daysRemaining}})
+
+
+ Select a payment period to activate your shop:
+
+
+
+
+ Annually ($45 x 12 months)
+ MOST POPULAR SAVE $60
+
+
+
+
+
+ Monthly ($50 each month)
+
+
+
+
Due: $540 for 12 months
+
+
+
+ By clicking Sign Up Now, you agree that to ensure uninterrupted service, your shop will be set to continuous auto-renewal payments of $540 for 12 months with your next payment due on {{yearlyPaymentDate}} . You can cancel your service or disable auto-renewal at any time by contacting us . You agree to our Terms of Service and Privacy Policy .
+
+
+
+ Sign Up Now!
+
+ {{/if}}
+
+
+
+
+
+
+
+
+
SSL Settings
+
+
+ {{#if launchdockStack.defaultDomain}}
+
Default Domain Name
+
+
+ https://{{launchdockStack.defaultDomain}}
+
+
+
+ {{/if}}
+
Custom Domain Name
+ {{#if settings.ssl.domain}}
+
+
+ https://{{settings.ssl.domain}}
+
+
+
+ {{else}}
+
Custom SSL not configured yet.
+
+ {{/if}}
+ {{#if launchdockStack.endpoint}}
+
Create a CNAME record for your domain that points to:
+
+
{{launchdockStack.endpoint}}
+ {{/if}}
+
+
+
+
+ {{else}}
+
+
+
+ {{/if}}
+
+
+ {{/with}}
+
diff --git a/imports/plugins/included/launchdock-connect/client/templates/dashboard.js b/imports/plugins/included/launchdock-connect/client/templates/dashboard.js
new file mode 100644
index 00000000000..d197e6ee125
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/client/templates/dashboard.js
@@ -0,0 +1,220 @@
+import moment from "moment";
+import { Meteor } from "meteor/meteor";
+import { Template } from "meteor/templating";
+import { Mongo } from "meteor/mongo";
+import { ReactiveVar } from "meteor/reactive-var";
+import { ReactiveStripe } from "meteor/jeremy:stripe";
+import { Packages } from "/lib/collections";
+import Launchdock from "../../lib/launchdock";
+
+Template.launchdockDashboard.onCreated(function () {
+ // create and return connection
+ const launchdock = Launchdock.connect();
+
+ // remote users collection (only contains current user)
+ this.LaunchdockUsers = new Mongo.Collection("users", {
+ connection: launchdock
+ });
+
+ // remote stacks collection (only contains current stack)
+ this.LaunchdockStacks = new Mongo.Collection("stacks", {
+ connection: launchdock
+ });
+
+ // remote settings collection (only contains Stripe public key)
+ this.LaunchdockSettings = new Mongo.Collection("settings", {
+ connection: launchdock
+ });
+
+ const user = Meteor.user();
+
+ // subscribe to user/stack details
+ this.ldStackSub = launchdock.subscribe("reaction-account-info", user.services.launchdock.stackId);
+
+ // Stripe public key for Launchdock
+ launchdock.subscribe("launchdock-stripe-public-key");
+
+ // setup Stripe client libs
+ this.autorun(() => {
+ this.stripeKey = new ReactiveVar();
+ const s = this.LaunchdockSettings.findOne();
+
+ if (s) {
+ const key = s.stripeLivePublishableKey || s.stripeTestPublishableKey;
+
+ // store key in ReactiveVar on template instance
+ this.stripeKey.set(key);
+
+ // load client side Stripe libs
+ ReactiveStripe.load(key);
+ }
+ });
+});
+
+
+Template.launchdockDashboard.helpers({
+
+ packageData() {
+ return Packages.findOne({
+ name: "reaction-connect"
+ });
+ },
+
+ launchdockDataReady() {
+ return Template.instance().ldStackSub.ready();
+ },
+
+ launchdockStack() {
+ return Template.instance().LaunchdockStacks.findOne();
+ },
+
+ trial() {
+ const stack = Template.instance().LaunchdockStacks.findOne();
+ // calculate the trial end date and days remaining
+ let ends;
+ let daysRemaining;
+ let daySuffix;
+
+ if (stack) {
+ let startDate = stack.createdAt;
+ ends = new Date();
+ ends.setDate(startDate.getDate() + 30);
+ const now = new Date();
+ const msPerDay = 24 * 60 * 60 * 1000;
+ const timeLeft = ends.getTime() - now.getTime();
+ const daysLeft = timeLeft / msPerDay;
+ daysRemaining = Math.floor(daysLeft);
+ daySuffix = daysRemaining === 1 ? " day" : " days";
+ }
+ return {
+ ends: moment(ends).format("MMMM Do YYYY"),
+ daysRemaining: daysRemaining + daySuffix
+ };
+ },
+
+ shopCreatedAt() {
+ const stack = Template.instance().LaunchdockStacks.findOne();
+ return stack ? moment(stack.createdAt).format("MMMM Do YYYY, h:mma") : "";
+ },
+
+ isSubscribed() {
+ const user = Template.instance().LaunchdockUsers.findOne();
+ return !!(user && user.subscription && user.subscription.status === "active");
+ },
+
+ plan() {
+ const user = Template.instance().LaunchdockUsers.findOne();
+ return user && user.subscription ? user.subscription.plan.name : null;
+ },
+
+ nextPayment() {
+ const user = Template.instance().LaunchdockUsers.findOne();
+ if (user && user.subscription) {
+ const nextPayment = user.subscription.next_payment;
+ return moment(nextPayment).format("LL");
+ }
+ return null;
+ },
+
+ yearlyPaymentDate() {
+ const today = new Date();
+ let nextDue = new Date();
+ nextDue.setDate(today.getDate() + 365);
+
+ return moment(nextDue).format("LL");
+ }
+});
+
+
+Template.launchdockDashboard.events({
+ // open settings panel
+ "click [data-event-action=showLaunchdockSettings]"() {
+ Reaction.showActionView({
+ label: "SSL Settings",
+ template: "connectSettings",
+ data: this
+ });
+ },
+
+ // change UI based on which subscription option is chosen
+ "change input[name='plan-choice']"(e, t) {
+ const plan = t.$("input[name='plan-choice']:checked").val();
+
+ let dueToday;
+ let term;
+
+ if (plan === "Yearly") {
+ dueToday = "$540 for 12 months";
+ term = dueToday;
+ daysFromNow = 365;
+ } else {
+ dueToday = "$50 for first month";
+ term = "$50 per month";
+ daysFromNow = 30;
+ }
+
+ const today = new Date();
+ let nextDue = new Date();
+ nextDue.setDate(today.getDate() + daysFromNow);
+
+ t.$(".price").text(dueToday);
+ t.$(".term").text(term);
+ t.$(".next-due").text(moment(nextDue).format("LL"));
+ },
+
+ // trigger subscription checkout
+ "click .checkout"(e, t) {
+ e.preventDefault();
+
+ const stripeKey = Template.instance().stripeKey.get();
+
+ if (!stripeKey) {
+ Alerts.add("Unable to process a payment. Please contact support.", "danger");
+ }
+
+ const plan = t.$("input[name='plan-choice']:checked").val();
+
+ let price;
+
+ if (plan === "Yearly") {
+ price = 54000;
+ } else {
+ price = 5000;
+ }
+
+ const user = Meteor.user();
+
+ const charge = StripeCheckout.configure({
+ key: stripeKey,
+ image: "https://reactioncommerce.com/images/reaction-logo.png",
+ locale: "auto",
+ email: user.emails[0].address,
+ panelLabel: `Subscribe (${plan.toLowerCase()})`,
+ token: (token) => {
+ const options = {
+ cardToken: token.id,
+ plan: plan.toLowerCase(),
+ stackId: user.services.launchdock.stackId
+ };
+
+ const launchdock = Launchdock.connect();
+
+ launchdock.call("stripe/createCustomerAndSubscribe", options, (err) => {
+ if (err) {
+ Alerts.add("Unable to process a payment. Please contact support.", "danger");
+ } else {
+ Alerts.add("Thank you for your payment!", "success", {
+ autoHide: true
+ });
+ }
+ });
+ }
+ });
+
+ charge.open({
+ name: "Reaction Commerce",
+ description: `${plan} Subscription`,
+ amount: price
+ });
+ }
+});
diff --git a/imports/plugins/included/launchdock-connect/client/templates/dashboard.less b/imports/plugins/included/launchdock-connect/client/templates/dashboard.less
new file mode 100644
index 00000000000..4fd3469db0f
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/client/templates/dashboard.less
@@ -0,0 +1,83 @@
+@account-status-brand-color: #238cc3;
+
+.account-status {
+
+ a {
+ color: darken(@account-status-brand-color, 10%);
+ font-weight: bold;
+ transition: color .7s;
+
+ &:hover {
+ color: darken(@account-status-brand-color, 30%);
+ text-decoration: none;
+ }
+ }
+
+ .trial-info {
+ color: darken(@account-status-brand-color, 30%);
+ margin-bottom: 2rem;
+ }
+
+ .trial-ends-date {
+ font-size: 1.4rem;
+ }
+
+ .plan-choice:nth-of-type(1) {
+ margin: 2rem 0 .8rem 0;
+ }
+
+ .most-popular {
+ background-color: #afe4fe;
+ padding: 2px 4px;
+ border-radius: 3px;
+ }
+
+ .price-breakdown {
+ font-weight: normal;
+ }
+
+
+ .due-today {
+ margin: 2rem 0;
+
+ h4 {
+ font-weight: bold;
+ color: darken(@account-status-brand-color, 30%);
+
+ .price {
+ font-size: 1.5rem;
+ font-weight: normal;
+ }
+ }
+ }
+
+ .terms {
+ font-size: 1.2rem;
+ margin-bottom: 3rem;
+ }
+
+ button.checkout {
+ background-color: @account-status-brand-color;
+ color: #fff;
+ padding-left: 70px;
+ padding-right: 70px;
+ border-radius: 2px;
+ display: block;
+ margin: 0 auto;
+ transition: all .5s;
+
+ &:hover {
+ background-color: darken(@account-status-brand-color, 15%);
+ box-shadow: 0 2px 2px 1px rgba(0, 0, 0, .25);
+ }
+ }
+}
+
+.connect-account.loading {
+ width: 50px;
+ margin: 40px auto;
+
+ .fa-spin {
+ font-size: 55px;
+ }
+}
diff --git a/imports/plugins/included/launchdock-connect/client/templates/settings.html b/imports/plugins/included/launchdock-connect/client/templates/settings.html
new file mode 100644
index 00000000000..783f0095fc3
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/client/templates/settings.html
@@ -0,0 +1,45 @@
+
+
+ {{#with packageData}}
+
+ {{/with}}
+
diff --git a/imports/plugins/included/launchdock-connect/client/templates/settings.js b/imports/plugins/included/launchdock-connect/client/templates/settings.js
new file mode 100644
index 00000000000..4390f64017f
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/client/templates/settings.js
@@ -0,0 +1,40 @@
+import { Template } from "meteor/templating";
+import { Packages } from "/lib/collections";
+
+
+Template.launchdockSettings.onCreated(function () {
+ this.subscribe("launchdock-auth");
+});
+
+
+Template.launchdockSettings.helpers({
+ packageData() {
+ return Packages.findOne({
+ name: "reaction-connect"
+ });
+ }
+});
+
+
+Template.launchdockSettings.events({
+ "submit #launchdock-ssl-update-form"(event, tmpl) {
+ event.preventDefault();
+
+ const opts = {
+ domain: tmpl.find("input[name='ssl-domain']").value,
+ privateKey: tmpl.find("textarea[name='ssl-private-key']").value,
+ publicCert: tmpl.find("textarea[name='ssl-certificate']").value
+ };
+
+ Meteor.call("launchdock/setCustomSsl", opts, (err) => {
+ if (err) {
+ Alerts.removeSeen();
+ Alerts.add("SSL settings update failed. " + err.reason, "danger");
+ return;
+ }
+ Alerts.add("SSL settings saved. Connecting to Launckdock...", "success", {
+ autoHide: true
+ });
+ });
+ }
+});
diff --git a/imports/plugins/included/launchdock-connect/lib/collections.js b/imports/plugins/included/launchdock-connect/lib/collections.js
new file mode 100644
index 00000000000..2d94b3759a7
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/lib/collections.js
@@ -0,0 +1,18 @@
+import * as Schemas from "/lib/collections/schemas";
+
+Schemas.LaunchdockPackageConfig = new SimpleSchema([
+ Schemas.PackageConfig, {
+ "settings.ssl.domain": {
+ type: String,
+ label: "Custom Domain"
+ },
+ "settings.ssl.privateKey": {
+ type: String,
+ label: "SSL Private Key"
+ },
+ "settings.ssl.certificate": {
+ type: String,
+ label: "SSL Certificate"
+ }
+ }
+]);
diff --git a/imports/plugins/included/launchdock-connect/lib/launchdock.js b/imports/plugins/included/launchdock-connect/lib/launchdock.js
new file mode 100644
index 00000000000..28418d9e80e
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/lib/launchdock.js
@@ -0,0 +1,49 @@
+import { Meteor } from "meteor/meteor";
+import { DDP } from "meteor/ddp";
+
+const Launchdock = {
+ /*
+ * Create authenticated DDP connection to Launchdock
+ */
+ connect() {
+ let url;
+ let username;
+ let pw;
+
+ /*
+ * client login info
+ */
+ if (Meteor.isClient) {
+ const user = Meteor.user();
+
+ if (!user || !user.services || !user.services.launchdock) {
+ return null;
+ }
+
+ url = user.services.launchdock.url;
+ username = user.services.launchdock.username;
+ pw = user.services.launchdock.auth;
+ }
+
+ /*
+ * server login info
+ */
+ if (Meteor.isServer) {
+ url = process.env.LAUNCHDOCK_URL;
+ username = process.env.LAUNCHDOCK_USERNAME;
+ pw = process.env.LAUNCHDOCK_AUTH;
+ }
+
+ if (!url || !username || !pw) {
+ return null;
+ }
+
+ // create and return connection
+ const launchdock = DDP.connect(url);
+ DDP.loginWithPassword(launchdock, { username: username }, pw);
+
+ return launchdock;
+ }
+};
+
+export default Launchdock;
diff --git a/imports/plugins/included/launchdock-connect/register.js b/imports/plugins/included/launchdock-connect/register.js
new file mode 100644
index 00000000000..b5b0d762be9
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/register.js
@@ -0,0 +1,32 @@
+import { Reaction } from "/server/api";
+
+Reaction.registerPackage({
+ label: "Reaction Connect",
+ name: "reaction-connect",
+ icon: "fa fa-rocket",
+ autoEnable: true,
+ settings: {
+ name: "Connect"
+ },
+ registry: [
+ {
+ provides: "dashboard",
+ label: "Connect",
+ name: "reaction-connect",
+ route: "/dashboard/connect",
+ description: "Connect Reaction as a deployed service",
+ icon: "fa fa-rocket",
+ priority: 1,
+ container: "utilities",
+ template: "connectDashboard"
+ },
+ {
+ provides: "settings",
+ route: "/dashboard/connect/settings",
+ name: "reaction-connect/settings",
+ label: "Reaction Connect",
+ container: "reaction-connect",
+ template: "connectSettings"
+ }
+ ]
+});
diff --git a/imports/plugins/included/launchdock-connect/server/hooks.js b/imports/plugins/included/launchdock-connect/server/hooks.js
new file mode 100644
index 00000000000..bb4d9e438f7
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/server/hooks.js
@@ -0,0 +1,29 @@
+import { Meteor } from "meteor/meteor";
+import { Hooks, Logger } from "/server/api";
+
+/**
+ * Hook to setup default admin user with Launchdock credentials (if they exist)
+ */
+
+Hooks.Events.add("afterCreateDefaultAdminUser", (user) => {
+ if (process.env.LAUNCHDOCK_USERID) {
+ Meteor.users.update({
+ _id: user._id
+ }, {
+ $set: {
+ "services.launchdock.userId": process.env.LAUNCHDOCK_USERID,
+ "services.launchdock.username": process.env.LAUNCHDOCK_USERNAME,
+ "services.launchdock.auth": process.env.LAUNCHDOCK_AUTH,
+ "services.launchdock.url": process.env.LAUNCHDOCK_URL,
+ "services.launchdock.stackId": process.env.LAUNCHDOCK_STACK_ID
+ }
+ }, (err) => {
+ if (err) {
+ Logger.error(err);
+ } else {
+ Logger.info("Updated default admin with Launchdock account info.");
+ }
+ });
+ }
+ return user;
+});
diff --git a/imports/plugins/included/launchdock-connect/server/index.js b/imports/plugins/included/launchdock-connect/server/index.js
new file mode 100644
index 00000000000..14bddab98df
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/server/index.js
@@ -0,0 +1,5 @@
+import "../lib/collections";
+
+import "./hooks";
+import "./methods";
+import "./publications";
diff --git a/imports/plugins/included/launchdock-connect/server/methods.js b/imports/plugins/included/launchdock-connect/server/methods.js
new file mode 100644
index 00000000000..16a932fb56b
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/server/methods.js
@@ -0,0 +1,97 @@
+import { Meteor } from "meteor/meteor";
+import { check } from "meteor/check";
+import { Packages } from "/lib/collections";
+import { Reaction, Logger } from "/server/api";
+import Launchdock from "../lib/launchdock";
+
+Meteor.methods({
+ /**
+ * Sets custom domain name, confirms SSL key/cert exists.
+ * @param {Object} opts - custom SSL cert details
+ * @return {Boolean} - returns true on successful update
+ */
+ "launchdock/setCustomSsl"(opts) {
+ if (!Reaction.hasAdminAccess()) {
+ const err = "Access denied";
+ Logger.error(err);
+ throw new Meteor.Error("auth-error", err);
+ }
+
+ if (!process.env.LAUNCHDOCK_USERID) {
+ const err = "Launchdock credentials not found";
+ Logger.error(err);
+ throw new Meteor.Error("launchdock-credential-error", err);
+ }
+
+ check(opts, {
+ domain: String,
+ privateKey: String,
+ publicCert: String
+ });
+
+ this.unblock();
+
+ const ldConnect = Packages.findOne({
+ name: "reaction-connect"
+ });
+
+ // save everything locally
+ try {
+ Packages.update(ldConnect._id, {
+ $set: {
+ "settings.ssl.domain": opts.domain,
+ "settings.ssl.privateKey": opts.privateKey,
+ "settings.ssl.certificate": opts.publicCert
+ }
+ });
+ } catch (e) {
+ Logger.error(e);
+ throw new Meteor.Error(e);
+ }
+
+ // build args for method on Launchdock side
+ const stackId = process.env.LAUNCHDOCK_STACK_ID;
+ const ldArgs = {
+ name: opts.domain,
+ key: opts.privateKey,
+ cert: opts.publicCert
+ };
+
+ const launchdock = Launchdock.connect(ldUrl);
+
+ if (!launchdock) {
+ const err = "Unable to connect to Launchdock";
+ Logger.error(err);
+ throw new Meteor.Error(err);
+ }
+
+ const result = launchdock.call("rancher/updateStackSSLCert", stackId, ldArgs);
+
+ launchdock.disconnect();
+
+ return result;
+ },
+
+
+ "launchdock/getDefaultDomain"() {
+ if (!Reaction.hasAdminAccess()) {
+ const err = "Access denied";
+ Logger.error(err);
+ throw new Meteor.Error("auth-error", err);
+ }
+
+ return process.env.LAUNCHDOCK_DEFAULT_DOMAIN;
+ },
+
+
+ "launchdock/getLoadBalancerEndpoint"() {
+ if (!Reaction.hasAdminAccess()) {
+ const err = "Access denied";
+ Logger.error(err);
+ throw new Meteor.Error("auth-error", err);
+ }
+
+ return process.env.LAUNCHDOCK_BALANCER_ENDPOINT;
+ }
+
+});
diff --git a/imports/plugins/included/launchdock-connect/server/publications.js b/imports/plugins/included/launchdock-connect/server/publications.js
new file mode 100644
index 00000000000..2f46b91a016
--- /dev/null
+++ b/imports/plugins/included/launchdock-connect/server/publications.js
@@ -0,0 +1,19 @@
+import { Meteor } from "meteor/meteor";
+import { Roles } from "meteor/alanning:roles";
+
+
+Meteor.publish("launchdock-auth", function () {
+ // only publish Launchdock credentials for logged in admin/owner
+ if (Roles.userIsInRole(this.userId, ["admin", "owner"])) {
+ return Meteor.users.find({ _id: this.userId }, {
+ fields: {
+ "services.launchdock.userId": 1,
+ "services.launchdock.username": 1,
+ "services.launchdock.auth": 1,
+ "services.launchdock.url": 1,
+ "services.launchdock.stackId": 1
+ }
+ });
+ }
+ return this.ready();
+});
diff --git a/imports/plugins/included/paypal/client/templates/checkout/payflowForm.js b/imports/plugins/included/paypal/client/templates/checkout/payflowForm.js
index 32e0fd48552..840fd54fc36 100644
--- a/imports/plugins/included/paypal/client/templates/checkout/payflowForm.js
+++ b/imports/plugins/included/paypal/client/templates/checkout/payflowForm.js
@@ -46,7 +46,7 @@ function handlePaypalSubmitError(error) {
}
return results;
} else if (serverError) {
- return paymentAlert("Oops! " + serverError);
+ return paymentAlert("Oops! Credit Card number is invalid.");
}
Logger.fatal("An unknown error has occurred while processing a Paypal payment");
return paymentAlert("Oops! An unknown error has occurred");
diff --git a/imports/plugins/included/paypal/lib/collections/schemas/paypal.js b/imports/plugins/included/paypal/lib/collections/schemas/paypal.js
index 87991fd8292..9157e73afe4 100644
--- a/imports/plugins/included/paypal/lib/collections/schemas/paypal.js
+++ b/imports/plugins/included/paypal/lib/collections/schemas/paypal.js
@@ -67,7 +67,8 @@ export const PaypalPayment = new SimpleSchema({
},
cardNumber: {
type: String,
- min: 16,
+ min: 12,
+ max: 19,
label: "Card number"
},
expireMonth: {
diff --git a/imports/plugins/included/paypal/server/methods/express.js b/imports/plugins/included/paypal/server/methods/express.js
index 211752de0bc..895c893f08e 100644
--- a/imports/plugins/included/paypal/server/methods/express.js
+++ b/imports/plugins/included/paypal/server/methods/express.js
@@ -159,6 +159,8 @@ Meteor.methods({
let currencycode = paymentMethod.transactions[0].CURRENCYCODE;
let response;
+ // 100% discounts are not valid when using PayPal Express
+ // If discount is 100%, void authorization instead of applying discount
if (amount === accounting.toFixed(0, 2)) {
try {
response = HTTP.post(options.url, {
diff --git a/imports/plugins/included/paypal/server/methods/payflow.js b/imports/plugins/included/paypal/server/methods/payflow.js
index 9065cd9a8ad..1f4cd88779a 100644
--- a/imports/plugins/included/paypal/server/methods/payflow.js
+++ b/imports/plugins/included/paypal/server/methods/payflow.js
@@ -1,181 +1,10 @@
-import PayFlow from "paypal-rest-sdk"; // PayFlow is PayPal PayFlow lib
-import moment from "moment";
+import * as PayflowproMethods from "./payflowproMethods";
import { Meteor } from "meteor/meteor";
-import { check } from "meteor/check";
-import { Reaction, Logger } from "/server/api";
-import { Shops } from "/lib/collections";
-import { Paypal } from "../../lib/api"; // Paypal is the reaction api
Meteor.methods({
- /**
- * payflowpro/payment/submit
- * Create and Submit a PayPal PayFlow transaction
- * @param {Object} transactionType transactionType
- * @param {Object} cardData cardData object
- * @param {Object} paymentData paymentData object
- * @return {Object} results from PayPal payment create
- */
- "payflowpro/payment/submit": function (transactionType, cardData, paymentData) {
- check(transactionType, String);
- check(cardData, Object);
- check(paymentData, Object);
- this.unblock();
-
- PayFlow.configure(Paypal.payflowAccountOptions());
-
- let paymentObj = Paypal.paymentObj();
- paymentObj.intent = transactionType;
- paymentObj.payer.funding_instruments.push(Paypal.parseCardData(cardData));
- paymentObj.transactions.push(Paypal.parsePaymentData(paymentData));
- const wrappedFunc = Meteor.wrapAsync(PayFlow.payment.create, PayFlow.payment);
- let result;
- try {
- result = {
- saved: true,
- response: wrappedFunc(paymentObj)
- };
- } catch (error) {
- Logger.warn(error);
- result = {
- saved: false,
- error: error
- };
- }
- return result;
- },
-
-
- /**
- * payflowpro/payment/capture
- * Capture an authorized PayPal transaction
- * @param {Object} paymentMethod A PaymentMethod object
- * @return {Object} results from PayPal normalized
- */
- "payflowpro/payment/capture": function (paymentMethod) {
- check(paymentMethod, Reaction.Schemas.PaymentMethod);
- this.unblock();
-
- PayFlow.configure(Paypal.payflowAccountOptions());
-
- let result;
- // TODO: This should be changed to some ReactionCore method
- const shop = Shops.findOne(Reaction.getShopId());
- const wrappedFunc = Meteor.wrapAsync(PayFlow.authorization.capture, PayFlow.authorization);
- let captureTotal = Math.round(parseFloat(paymentMethod.amount) * 100) / 100;
- const captureDetails = {
- amount: {
- currency: shop.currency,
- total: captureTotal
- },
- is_final_capture: true // eslint-disable-line camelcase
- };
-
- try {
- const response = wrappedFunc(paymentMethod.metadata.authorizationId, captureDetails);
-
- result = {
- saved: true,
- metadata: {
- parentPaymentId: response.parent_payment,
- captureId: response.id
- },
- rawTransaction: response
- };
- } catch (error) {
- Logger.warn(error);
- result = {
- saved: false,
- error: error
- };
- }
- return result;
- },
-
- "payflowpro/refund/create": function (paymentMethod, amount) {
- check(paymentMethod, Reaction.Schemas.PaymentMethod);
- check(amount, Number);
- this.unblock();
-
- PayFlow.configure(Paypal.payflowAccountOptions());
-
- let createRefund = Meteor.wrapAsync(PayFlow.capture.refund, PayFlow.capture);
- let result;
-
- try {
- Logger.debug("payflowpro/refund/create: paymentMethod.metadata.captureId", paymentMethod.metadata.captureId);
- let response = createRefund(paymentMethod.metadata.captureId, {
- amount: {
- total: amount,
- currency: "USD"
- }
- });
-
- result = {
- saved: true,
- type: "refund",
- created: response.create_time,
- amount: response.amount.total,
- currency: response.amount.currency,
- rawTransaction: response
- };
- } catch (error) {
- result = {
- saved: false,
- error: error
- };
- }
- return result;
- },
-
- "payflowpro/refund/list": function (paymentMethod) {
- check(paymentMethod, Reaction.Schemas.PaymentMethod);
- this.unblock();
-
- PayFlow.configure(Paypal.payflowAccountOptions());
-
- let listPayments = Meteor.wrapAsync(PayFlow.payment.get, PayFlow.payment);
- let result = [];
- // todo: review parentPaymentId vs authorizationId, are they both correct?
- // added authorizationId without fully understanding the intent of parentPaymentId
- // let authId = paymentMethod.metadata.parentPaymentId || paymentMethod.metadata.authorizationId;
- let authId = paymentMethod.metadata.transactionId;
-
- if (authId) {
- Logger.debug("payflowpro/refund/list: paymentMethod.metadata.parentPaymentId", authId);
- try {
- let response = listPayments(authId);
-
- for (let transaction of response.transactions) {
- for (let resource of transaction.related_resources) {
- if (_.isObject(resource.refund)) {
- if (resource.refund.state === "completed") {
- result.push({
- type: "refund",
- created: moment(resource.refund.create_time).unix() * 1000,
- amount: Math.abs(resource.refund.amount.total),
- currency: resource.refund.amount.currency,
- raw: response
- });
- }
- }
- }
- }
- } catch (error) {
- Logger.warn("Failed payflowpro/refund/list", error);
- result = {
- error: error
- };
- }
- }
- return result;
- },
-
- "payflowpro/settings": function () {
- let settings = Paypal.payflowAccountOptions();
- let payflowSettings = {
- mode: settings.mode,
- enabled: settings.enabled
- };
- return payflowSettings;
- }
+ "payflowpro/payment/submit": PayflowproMethods.paymentSubmit,
+ "payflowpro/payment/capture": PayflowproMethods.paymentCapture,
+ "payflowpro/refund/create": PayflowproMethods.createRefund,
+ "payflowpro/refund/list": PayflowproMethods.listRefunds,
+ "payflowpro/settings": PayflowproMethods.getSettings
});
diff --git a/imports/plugins/included/paypal/server/methods/payflowpro-methods-refund.app-test.js b/imports/plugins/included/paypal/server/methods/payflowpro-methods-refund.app-test.js
new file mode 100644
index 00000000000..8b4c5847fe1
--- /dev/null
+++ b/imports/plugins/included/paypal/server/methods/payflowpro-methods-refund.app-test.js
@@ -0,0 +1,103 @@
+/* eslint camelcase: 0 */
+import { Meteor } from "meteor/meteor";
+import { expect } from "meteor/practicalmeteor:chai";
+import { sinon } from "meteor/practicalmeteor:sinon";
+import { PayflowproApi } from "./payflowproApi";
+
+describe("payflowpro/refund/create", function () {
+ let sandbox;
+
+ beforeEach(function () {
+ sandbox = sinon.sandbox.create();
+ });
+
+ afterEach(function () {
+ sandbox.restore();
+ });
+
+ it("Should call payflowpro/refund/create with the proper parameters and return saved = true", function (done) {
+ let paymentMethod = {
+ processor: "PayflowPro",
+ storedCard: "Visa 0322",
+ method: "credit_card",
+ authorization: "17E47122C3842243W",
+ transactionId: "PAY-2M9650078C535230RK6YVLQY",
+ metadata: {
+ transactionId: "PAY-2M9650078C535230RK6YVLQY",
+ authorizationId: "17E47122C3842243W",
+ parentPaymentId: "PAY-2M9650078C535230RK6YVLQY",
+ captureId: "4F639165YD1630705"
+ },
+ amount: 74.93,
+ status: "completed",
+ mode: "capture",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ workflow: {
+ status: "new"
+ }
+ };
+
+
+ let payflowproRefundResult = {
+ saved: true,
+ type: "refund",
+ created: "2016-08-15T05:58:14Z",
+ amount: "2.47",
+ currency: "USD",
+ rawTransaction: {
+ id: "23546021UW746214P",
+ create_time: "2016-08-15T05:58:14Z",
+ update_time: "2016-08-15T05:58:14Z",
+ state: "completed",
+ amount: {
+ total: "2.47",
+ currency: "USD"
+ },
+ capture_id: "4F639165YD1630705",
+ parent_payment: "PAY-2M9650078C535230RK6YVLQY",
+ links: [{
+ href: "https://api.sandbox.paypal.com/v1/payments/refund/23546021UW746214P",
+ rel: "self",
+ method: "GET"
+ }, {
+ href: "https://api.sandbox.paypal.com/v1/payments/payment/PAY-2M9650078C535230RK6YVLQY",
+ rel: "parent_payment",
+ method: "GET"
+ }, {
+ href: "https://api.sandbox.paypal.com/v1/payments/capture/4F639165YD1630705",
+ rel: "capture",
+ method: "GET"
+ }],
+ httpStatusCode: 201
+ }
+ };
+
+
+ sandbox.stub(PayflowproApi.apiCall, "createRefund", function () {
+ return payflowproRefundResult;
+ });
+
+
+ let refundResult = null;
+ let refundError = null;
+
+
+ Meteor.call("payflowpro/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;
+ // expect(BraintreeApi.apiCall.createRefund).to.have.been.calledWith({
+ // createRefund: {
+ // amount: 99.95,
+ // transactionId: paymentMethod.transactionId
+ // }
+ // });
+ done();
+ });
+});
diff --git a/imports/plugins/included/paypal/server/methods/payflowproApi.js b/imports/plugins/included/paypal/server/methods/payflowproApi.js
new file mode 100644
index 00000000000..3f353a1df77
--- /dev/null
+++ b/imports/plugins/included/paypal/server/methods/payflowproApi.js
@@ -0,0 +1,170 @@
+import PayFlow from "paypal-rest-sdk"; // PayFlow is PayPal PayFlow lib
+import moment from "moment";
+import accounting from "accounting-js";
+import { Meteor } from "meteor/meteor";
+import { Reaction, Logger } from "/server/api";
+import { Shops } from "/lib/collections";
+import { Paypal } from "../../lib/api"; // Paypal is the reaction api
+
+export const PayflowproApi = {};
+PayflowproApi.apiCall = {};
+
+
+PayflowproApi.apiCall.paymentSubmit = function (paymentSubmitDetails) {
+ PayFlow.configure(Paypal.payflowAccountOptions());
+
+ let paymentObj = Paypal.paymentObj();
+ paymentObj.intent = paymentSubmitDetails.transactionType;
+ paymentObj.payer.funding_instruments.push(Paypal.parseCardData(paymentSubmitDetails.cardData));
+ paymentObj.transactions.push(Paypal.parsePaymentData(paymentSubmitDetails.paymentData));
+ const wrappedFunc = Meteor.wrapAsync(PayFlow.payment.create, PayFlow.payment);
+ let result;
+ try {
+ result = {
+ saved: true,
+ response: wrappedFunc(paymentObj)
+ };
+ } catch (error) {
+ Logger.warn(error);
+ result = {
+ saved: false,
+ error: error
+ };
+ }
+ return result;
+};
+
+
+PayflowproApi.apiCall.captureCharge = function (paymentCaptureDetails) {
+ PayFlow.configure(Paypal.payflowAccountOptions());
+
+ let result;
+ // TODO: This should be changed to some ReactionCore method
+ const shop = Shops.findOne(Reaction.getShopId());
+ const wrappedFunc = Meteor.wrapAsync(PayFlow.authorization.capture, PayFlow.authorization);
+ const wrappedFuncVoid = Meteor.wrapAsync(PayFlow.authorization.void, PayFlow.authorization);
+ let captureTotal = Math.round(parseFloat(paymentCaptureDetails.amount) * 100) / 100;
+ const captureDetails = {
+ amount: {
+ currency: shop.currency,
+ total: captureTotal
+ },
+ is_final_capture: true // eslint-disable-line camelcase
+ };
+ const capturedAmount = accounting.toFixed(captureDetails.amount.total, 2);
+
+ if (capturedAmount === accounting.toFixed(0, 2)) {
+ try {
+ const response = wrappedFuncVoid(paymentCaptureDetails.authorizationId, captureDetails);
+
+ result = {
+ saved: true,
+ metadata: {
+ parentPaymentId: response.parent_payment,
+ captureId: response.id
+ },
+ rawTransaction: response
+ };
+ } catch (error) {
+ Logger.warn(error);
+ result = {
+ saved: false,
+ error: error
+ };
+ }
+ return result;
+ }
+ try {
+ const response = wrappedFunc(paymentCaptureDetails.authorizationId, captureDetails);
+
+ result = {
+ saved: true,
+ metadata: {
+ parentPaymentId: response.parent_payment,
+ captureId: response.id
+ },
+ rawTransaction: response
+ };
+ } catch (error) {
+ Logger.warn(error);
+ result = {
+ saved: false,
+ error: error
+ };
+ }
+ return result;
+};
+
+
+PayflowproApi.apiCall.createRefund = function (refundDetails) {
+ PayFlow.configure(Paypal.payflowAccountOptions());
+
+ let createRefund = Meteor.wrapAsync(PayFlow.capture.refund, PayFlow.capture);
+ let result;
+
+ try {
+ Logger.debug("payflowpro/refund/create: paymentMethod.metadata.captureId", refundDetails.captureId);
+ let response = createRefund(refundDetails.captureId, {
+ amount: {
+ total: refundDetails.amount,
+ currency: "USD"
+ }
+ });
+
+ result = {
+ saved: true,
+ type: "refund",
+ created: response.create_time,
+ amount: response.amount.total,
+ currency: response.amount.currency,
+ rawTransaction: response
+ };
+ } catch (error) {
+ result = {
+ saved: false,
+ error: error
+ };
+ }
+ return result;
+};
+
+
+PayflowproApi.apiCall.listRefunds = function (refundListDetails) {
+ PayFlow.configure(Paypal.payflowAccountOptions());
+
+ let listPayments = Meteor.wrapAsync(PayFlow.payment.get, PayFlow.payment);
+ let result = [];
+ // todo: review parentPaymentId vs authorizationId, are they both correct?
+ // added authorizationId without fully understanding the intent of parentPaymentId
+ // let authId = paymentMethod.metadata.parentPaymentId || paymentMethod.metadata.authorizationId;
+ let authId = refundListDetails.transactionId;
+
+ if (authId) {
+ Logger.debug("payflowpro/refund/list: paymentMethod.metadata.parentPaymentId", authId);
+ try {
+ let response = listPayments(authId);
+
+ for (let transaction of response.transactions) {
+ for (let resource of transaction.related_resources) {
+ if (_.isObject(resource.refund)) {
+ if (resource.refund.state === "completed") {
+ result.push({
+ type: "refund",
+ created: moment(resource.refund.create_time).unix() * 1000,
+ amount: Math.abs(resource.refund.amount.total),
+ currency: resource.refund.amount.currency,
+ raw: response
+ });
+ }
+ }
+ }
+ }
+ } catch (error) {
+ Logger.warn("Failed payflowpro/refund/list", error);
+ result = {
+ error: error
+ };
+ }
+ }
+ return result;
+};
diff --git a/imports/plugins/included/paypal/server/methods/payflowproMethods.js b/imports/plugins/included/paypal/server/methods/payflowproMethods.js
new file mode 100644
index 00000000000..4322b545dee
--- /dev/null
+++ b/imports/plugins/included/paypal/server/methods/payflowproMethods.js
@@ -0,0 +1,154 @@
+import { PayflowproApi } from "./payflowproApi";
+import { Logger } from "/server/api";
+import { PaymentMethod } from "/lib/collections/schemas";
+import { check } from "meteor/check";
+import { Paypal } from "../../lib/api"; // Paypal is the reaction api
+
+
+/**
+ * payflowpro/payment/submit
+ * Create and Submit a PayPal PayFlow transaction
+ * @param {Object} transactionType transactionType
+ * @param {Object} cardData cardData object
+ * @param {Object} paymentData paymentData object
+ * @return {Object} results from PayPal payment create
+ */
+export function paymentSubmit(transactionType, cardData, paymentData) {
+ check(transactionType, String);
+ check(cardData, Object);
+ check(paymentData, Object);
+
+ const paymentSubmitDetails = {
+ transactionType: transactionType,
+ cardData: cardData,
+ paymentData: paymentData
+ };
+
+ let result;
+
+ try {
+ let refundResult = PayflowproApi.apiCall.paymentSubmit(paymentSubmitDetails);
+ Logger.info(refundResult);
+ result = refundResult;
+ } catch (error) {
+ Logger.error(error);
+ result = {
+ saved: false,
+ error: `Cannot Submit Payment: ${error.message}`
+ };
+ Logger.fatal("PayFlowPro call failed, payment was not submitted");
+ }
+
+ return result;
+}
+
+
+/**
+ * payflowpro/payment/capture
+ * Capture an authorized PayPal PayFlow transaction
+ * @param {Object} paymentMethod A PaymentMethod object
+ * @return {Object} results from PayPal normalized
+ */
+export function paymentCapture(paymentMethod) {
+ check(paymentMethod, PaymentMethod);
+
+ const paymentCaptureDetails = {
+ authorizationId: paymentMethod.metadata.authorizationId,
+ amount: paymentMethod.amount
+ };
+
+ let result;
+
+ try {
+ let refundResult = PayflowproApi.apiCall.captureCharge(paymentCaptureDetails);
+ Logger.info(refundResult);
+ result = refundResult;
+ } catch (error) {
+ Logger.error(error);
+ result = {
+ saved: false,
+ error: `Cannot Capture Payment: ${error.message}`
+ };
+ Logger.fatal("PayFlowPro call failed, payment was not captured");
+ }
+
+ return result;
+}
+
+
+/**
+ * createRefund
+ * Refund PayPal PayFlow payment
+ * @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 = {
+ captureId: paymentMethod.metadata.captureId,
+ amount: amount
+ };
+
+ let result;
+
+ try {
+ let refundResult = PayflowproApi.apiCall.createRefund(refundDetails);
+ Logger.info(refundResult);
+ result = refundResult;
+ } catch (error) {
+ Logger.error(error);
+ result = {
+ saved: false,
+ error: `Cannot issue refund: ${error.message}`
+ };
+ Logger.fatal("PaypalPro call failed, refund was not issued");
+ }
+
+ return result;
+}
+
+
+/**
+ * listRefunds
+ * List all refunds for a PayPal PayFlow 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, PaymentMethod);
+
+ const refundListDetails = {
+ transactionId: paymentMethod.metadata.transactionId
+ };
+
+ let result;
+
+ try {
+ let refundResult = PayflowproApi.apiCall.listRefunds(refundListDetails);
+ Logger.info(refundResult);
+ result = refundResult;
+ } catch (error) {
+ Logger.error(error);
+ result = {
+ saved: false,
+ error: `Cannot issue refund: ${error.message}`
+ };
+ Logger.fatal("PaypalPro call failed, refund was not issued");
+ }
+
+ return result;
+}
+
+
+export function getSettings() {
+ let settings = Paypal.payflowAccountOptions();
+ let payflowSettings = {
+ mode: settings.mode,
+ enabled: settings.enabled
+ };
+ return payflowSettings;
+}
diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantForm/variantForm.html b/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantForm/variantForm.html
index 919e2e86240..6eef8af98b4 100644
--- a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantForm/variantForm.html
+++ b/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantForm/variantForm.html
@@ -34,7 +34,16 @@