diff --git a/.codeclimate.yml b/.codeclimate.yml index 294ffb13294..1d9b1639232 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -7,6 +7,7 @@ exclude_paths: engines: eslint: enabled: true + channel: "eslint-2" csslint: enabled: false duplication: diff --git a/.eslintignore b/.eslintignore index 96212a3593b..ca441ef1b09 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1 @@ -**/*{.,-}min.js +*.min.* diff --git a/.eslintrc b/.eslintrc index a56f7221fee..997b9dd6a02 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,8 +9,7 @@ "ecmaVersion": 6, "sourceType": "module" }, - "plugins": ["react", "meteor"], - "extends": ["plugin:meteor/recommended"], + "plugins": ["react"], "ecmaFeatures": { "arrowFunctions": true, "blockBindings": true, @@ -168,7 +167,7 @@ /** * Style */ - "indent": [2, 2], // http://eslint.org/docs/rules/indent + "indent": [2, 2, {"SwitchCase": 1}], // http://eslint.org/docs/rules/indent "brace-style": [2, // http://eslint.org/docs/rules/brace-style "1tbs", { "allowSingleLine": true @@ -194,7 +193,7 @@ }], "new-cap": [0, { // http://eslint.org/docs/rules/new-cap (turned off for now, as it complains on all Match) "newIsCap": true, - "capIsNewExceptions": ["Match", "OneOf", "Optional"], + "capIsNewExceptions": ["Match", "OneOf", "Optional"] }], "no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines "max": 2 @@ -221,6 +220,6 @@ }], // http://eslint.org/docs/rules/space-before-function-paren "space-infix-ops": 2, // http://eslint.org/docs/rules/space-infix-ops "space-in-parens": [2, "never"], // http://eslint.org/docs/rules/space-in-parens - "spaced-comment": [2, "always"], // http://eslint.org/docs/rules/spaced-comment + "spaced-comment": [2, "always"] // http://eslint.org/docs/rules/spaced-comment } } diff --git a/.meteor/packages b/.meteor/packages index e782d86bf74..3b6f05b393a 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -58,6 +58,7 @@ cfs:gridfs cfs:standard-packages cfs:storage-adapter cfs:ui +jeremy:stripe jparker:gravatar juliancwirko:s-alert juliancwirko:s-alert-stackslide @@ -73,6 +74,7 @@ raix:ui-dropped-event risul:moment-timezone tmeasday:publish-counts vsivsi:job-collection +react-meteor-data # Testing packages dburles:factory diff --git a/.meteor/versions b/.meteor/versions index 37dd1994ced..05273e8a078 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -1,8 +1,8 @@ -accounts-base@1.2.9 +accounts-base@1.2.10 accounts-facebook@1.0.10 accounts-google@1.0.10 accounts-oauth@1.1.13 -accounts-password@1.2.12 +accounts-password@1.2.14 accounts-twitter@1.1.11 alanning:roles@1.2.15 aldeed:autoform@5.8.1 @@ -64,7 +64,7 @@ deps@1.0.12 diff-sequence@1.0.6 dispatch:mocha@0.0.9 ecmascript@0.5.7 -ecmascript-runtime@0.3.12 +ecmascript-runtime@0.3.13 ejson@1.0.12 email@1.1.16 es5-shim@4.6.13 @@ -77,12 +77,13 @@ html-tools@1.0.10 htmljs@1.0.10 http@1.2.8 id-map@1.0.8 +jeremy:stripe@1.6.0 jparker:crypto-core@0.1.0 jparker:crypto-md5@0.1.1 jparker:gravatar@0.5.1 jquery@1.11.9 juliancwirko:postcss@1.1.1 -juliancwirko:s-alert@3.1.4 +juliancwirko:s-alert@3.2.0 juliancwirko:s-alert-stackslide@3.1.3 kadira:blaze-layout@2.3.0 kadira:dochead@1.5.0 @@ -115,11 +116,10 @@ mongo@1.1.10 mongo-id@1.0.5 mongo-livedata@1.0.12 mrt:later@1.6.1 -npm-bcrypt@0.8.7 +npm-bcrypt@0.8.7_1 npm-mongo@1.5.45 -npm-node-aes-gcm@0.1.7_4 oauth@1.1.11 -oauth-encryption@1.1.13 +oauth-encryption@1.2.0 oauth1@1.1.10 oauth2@1.1.10 observe-sequence@1.0.12 @@ -133,6 +133,7 @@ raix:eventemitter@0.1.3 raix:ui-dropped-event@0.0.7 random@1.0.10 rate-limit@1.0.5 +react-meteor-data@0.2.9 reactive-dict@1.1.8 reactive-var@1.0.10 reload@1.1.10 @@ -148,6 +149,7 @@ srp@1.0.9 standard-minifier-js@1.1.8 templating@1.2.13 templating-tools@1.0.4 +tmeasday:check-npm-versions@0.3.1 tmeasday:publish-counts@0.7.3 tracker@1.1.0 twitter@1.1.12 diff --git a/.reaction/scripts/postinstall.sh b/.reaction/scripts/postinstall.sh deleted file mode 100755 index 203bfb068dd..00000000000 --- a/.reaction/scripts/postinstall.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -############################################################ -# This script runs automatically after every 'npm install' # -############################################################ - -# copy FontAwesome into project -cp -R node_modules/font-awesome/fonts ./public/ - -# setup plugin imports on client and server -bash .reaction/docker/scripts/plugin-loader.sh diff --git a/circle.yml b/circle.yml index 04c5b41e679..1efa76781a2 100644 --- a/circle.yml +++ b/circle.yml @@ -1,6 +1,6 @@ machine: node: - version: 0.10.46 + version: 4.4.7 services: - docker pre: diff --git a/client/modules/accounts/templates/dropdown/dropdown.js b/client/modules/accounts/templates/dropdown/dropdown.js index d016391ced4..4e0ad0c6c7f 100644 --- a/client/modules/accounts/templates/dropdown/dropdown.js +++ b/client/modules/accounts/templates/dropdown/dropdown.js @@ -30,10 +30,6 @@ Template.loginDropdown.events({ if (error) { Logger.warn("Failed to logout.", error); } - // go home on logout - Reaction.Subscriptions.Manager.reset(); - Reaction.Router.reload(); - Reaction.Router.go("/"); }); }, diff --git a/client/modules/core/helpers/templates.js b/client/modules/core/helpers/templates.js index 37a4cc299b5..6ad92881189 100644 --- a/client/modules/core/helpers/templates.js +++ b/client/modules/core/helpers/templates.js @@ -4,7 +4,7 @@ import * as Collections from "/lib/collections"; import * as Schemas from "/lib/collections/schemas"; import { Meteor } from "meteor/meteor"; import { Template } from "meteor/templating"; -import moment from "moment"; +import moment from "moment-timezone"; /* * diff --git a/client/modules/core/main.js b/client/modules/core/main.js index 889b1e1686a..effd6fb9361 100644 --- a/client/modules/core/main.js +++ b/client/modules/core/main.js @@ -8,6 +8,7 @@ import Logger from "/client/modules/logger"; import { Countries } from "/client/collections"; import { localeDep } from "/client/modules/i18n"; import { Packages, Shops } from "/lib/collections"; +import { Router } from "/client/modules/router"; /** * Reaction namespace @@ -77,56 +78,95 @@ export default { hasPermission(checkPermissions, checkUserId, checkGroup) { let group = this.getShopId(); let permissions = ["owner"]; + let id = ""; + const userId = checkUserId || this.userId || Meteor.userId(); + // + // local roleCheck function + // is the bulk of the logic + // called out a userId is validated. + // + function roleCheck() { + // permissions can be either a string or an array + // we'll force it into an array and use that + if (checkPermissions === undefined) { + permissions = ["owner"]; + } else if (typeof checkPermissions === "string") { + permissions = [checkPermissions]; + } else { + permissions = checkPermissions; + } + // if the user has admin, owner permissions we'll always check if those roles are enough + permissions.push("owner"); + permissions = _.uniq(permissions); - // default group to the shop or global if shop - // isn't defined for some reason. - if (checkGroup !== undefined && typeof checkGroup === "string") { - group = checkGroup; - } - if (!group) { - group = Roles.GLOBAL_GROUP; + // + // return if user has permissions in the group + // + if (Roles.userIsInRole(userId, permissions, group)) { + return true; + } + // global roles check + let sellerShopPermissions = Roles.getGroupsForUser(userId, "admin"); + // we're looking for seller permissions. + if (sellerShopPermissions) { + // loop through shops roles and check permissions + for (let key in sellerShopPermissions) { + if (key) { + let shop = sellerShopPermissions[key]; + if (Roles.userIsInRole(userId, permissions, shop)) { + return true; + } + } + } + } + // no specific permissions found returning false + return false; } - // use current user if userId if not provided - // becauase you gotta have a user to check permissions - const userId = checkUserId || this.userId || Meteor.userId(); - if (!userId) { + // + // check if a user id has been found + // in line 156 setTimeout + // + function validateUserId() { + if (Meteor.userId()) { + Meteor.clearTimeout(id); + Router.reload(); + return roleCheck(); + } return false; } - // permissions can be either a string or an array - // we'll force it into an array and use that - if (checkPermissions === undefined) { - permissions = ["owner"]; - } else if (typeof checkPermissions === "string") { - permissions = [checkPermissions]; - } else { - permissions = checkPermissions; - } - // if the user has admin, owner permissions we'll always check if those roles are enough - permissions.push("owner"); - permissions = _.uniq(permissions); // - // return if user has permissions in the group + // actual logic block to check permissions + // we'll bypass unecessary checks during + // a user logging, as we'll check again + // when everything is ready // - if (Roles.userIsInRole(userId, permissions, group)) { - return true; - } - // global roles check - let sellerShopPermissions = Roles.getGroupsForUser(userId, "admin"); - // we're looking for seller permissions. - if (sellerShopPermissions) { - // loop through shops roles and check permissions - for (let key in sellerShopPermissions) { - if (key) { - let shop = sellerShopPermissions[key]; - if (Roles.userIsInRole(userId, permissions, shop)) { - return true; - } - } + if (Meteor.loggingIn() === false) { + // + // this userId check happens because when logout + // occurs it takes a few cycles for a new anonymous user + // to get created and during this time the user has no + // permission, not even guest permissions so we + // need to wait and reload the routes. This + // mainly affects the logout from dashboard pages + // + if (!userId) { + id = Meteor.setTimeout(validateUserId, 5000); + } else { + return roleCheck(); + } + + // default group to the shop or global if shop + // isn't defined for some reason. + if (checkGroup !== undefined && typeof checkGroup === "string") { + group = checkGroup; + } + if (!group) { + group = Roles.GLOBAL_GROUP; } } - // no specific permissions found returning false + // return false to be safe return false; }, diff --git a/client/modules/core/subscriptions.js b/client/modules/core/subscriptions.js index 1413d26a1f9..a3278ecc154 100644 --- a/client/modules/core/subscriptions.js +++ b/client/modules/core/subscriptions.js @@ -28,17 +28,17 @@ Subscriptions.Account = Subscriptions.Manager.subscribe("Accounts", Meteor.userI /** * General Subscriptions */ -Subscriptions.Shops = Meteor.subscribe("Shops"); +Subscriptions.Shops = Subscriptions.Manager.subscribe("Shops"); -Subscriptions.Packages = Meteor.subscribe("Packages"); +Subscriptions.Packages = Subscriptions.Manager.subscribe("Packages"); -Subscriptions.Tags = Meteor.subscribe("Tags"); +Subscriptions.Tags = Subscriptions.Manager.subscribe("Tags"); -Subscriptions.Media = Meteor.subscribe("Media"); +Subscriptions.Media = Subscriptions.Manager.subscribe("Media"); // admin only // todo should we put this inside autorun and detect user changes -Subscriptions.Inventory = Meteor.subscribe("Inventory"); +Subscriptions.Inventory = Subscriptions.Manager.subscribe("Inventory"); /** * Subscriptions that need to reload on new sessions @@ -70,4 +70,5 @@ Tracker.autorun(() => { sessionId = Session.get("sessionId"); }); Subscriptions.Cart = Meteor.subscribe("Cart", sessionId, Meteor.userId()); + Subscriptions.UserProfile = Meteor.subscribe("UserProfile", Meteor.userId()); }); diff --git a/client/modules/router/hooks.js b/client/modules/router/hooks.js new file mode 100644 index 00000000000..7a77ec56411 --- /dev/null +++ b/client/modules/router/hooks.js @@ -0,0 +1,55 @@ + +/** + * Route Hook Methods + */ +const Hooks = { + _hooks: { + onEnter: {}, + onExit: {} + }, + + _addHook(type, routeName, callback) { + if (typeof this._hooks[type][routeName] === "undefined") { + this._hooks[type][routeName] = []; + } + this._hooks[type][routeName].push(callback); + }, + + onEnter(routeName, callback) { + // global onEnter callback + if (arguments.length === 1 && typeof arguments[0] === "function") { + const cb = routeName; + return this._addHook("onEnter", "GLOBAL", cb); + } + // route-specific onEnter callback + return this._addHook("onEnter", routeName, callback); + }, + + onExit(routeName, callback) { + // global onExit callback + if (arguments.length === 1 && typeof arguments[0] === "function") { + const cb = routeName; + return this._addHook("onExit", "GLOBAL", cb); + } + // route-specific onExit callback + return this._addHook("onExit", routeName, callback); + }, + + get(type, name) { + const group = this._hooks[type] || {}; + const callbacks = group[name]; + return (typeof callbacks !== "undefined" && !!callbacks.length) ? callbacks : []; + }, + + run(type, name, constant) { + const callbacks = this.get(type, name); + if (typeof callbacks !== "undefined" && !!callbacks.length) { + return callbacks.forEach((callback) => { + return callback(constant); + }); + } + return null; + } +}; + +export default Hooks; diff --git a/client/modules/router/main.js b/client/modules/router/main.js index de73056807a..5d61a2f8e63 100644 --- a/client/modules/router/main.js +++ b/client/modules/router/main.js @@ -1,11 +1,13 @@ +import _ from "lodash"; +import { Session } from "meteor/session"; +import { Meteor } from "meteor/meteor"; +import { Tracker } from "meteor/tracker"; import { FlowRouter as Router } from "meteor/kadira:flow-router-ssr"; import { BlazeLayout } from "meteor/kadira:blaze-layout"; import { Reaction, Logger } from "/client/api"; import { Packages, Shops } from "/lib/collections"; import { MetaData } from "/lib/api/router/metadata"; -import { Session } from "meteor/session"; -import { Meteor } from "meteor/meteor"; -import { Tracker } from "meteor/tracker"; +import Hooks from "./hooks"; // init flow-router @@ -15,6 +17,8 @@ import { Tracker } from "meteor/tracker"; // client should wait on subs Router.wait(); +Router.Hooks = Hooks; + /** * checkRouterPermissions * check if user has route permissions @@ -24,6 +28,7 @@ Router.wait(); */ function checkRouterPermissions(context) { const routeName = context.route.name; + if (Reaction.hasPermission(routeName, Meteor.userId())) { if (context.unauthorized === true) { delete context.unauthorized; @@ -44,8 +49,6 @@ function checkRouterPermissions(context) { return context; } -// initialize title and meta data and check permissions -Router.triggers.enter([checkRouterPermissions, MetaData.init]); /** * getRouteName @@ -170,7 +173,7 @@ Router.initPackageRoutes = () => { // // index / home route // to overide layout, ie: home page templates - // set DEFAULT_LAYOUT, in config.js + // set INDEX_OPTIONS, in config.js // shop.route("/", { name: "index", @@ -192,32 +195,26 @@ Router.initPackageRoutes = () => { route, template, layout, - workflow, - triggersEnter, - triggersExit + workflow } = registryItem; - // get registry route name - const routeName = getRegistryRouteName(pkg.name, registryItem); - // layout option structure - const options = { - template: template, - workflow: workflow, - layout: layout - }; + // console.log(registryItem); + + // get registry route name + const name = getRegistryRouteName(pkg.name, registryItem); // define new route // we could allow the options to be passed in the registry if we need to be more flexible const newRouteConfig = { - route: route, + route, options: { - name: routeName, - template: options.template, - layout: options.layout, - triggersEnter: triggersEnter, - triggersExit: triggersExit, - action: () => { - ReactionLayout(options); + name, + template, + layout, + triggersEnter: Router.Hooks.get("onEnter", name), + triggersExit: Router.Hooks.get("onExit", name), + action() { + ReactionLayout({ template, workflow, layout }); } } }; @@ -261,7 +258,6 @@ Router.initPackageRoutes = () => { Router.initialize(); } catch (e) { Logger.error(e); - Router.reload(); } } }; @@ -307,4 +303,14 @@ Router.isActiveClassName = (routeName) => { return routeDef === routeName ? "active" : ""; }; +// Register Global Route Hooks +Meteor.startup(() => { + Router.Hooks.onEnter(checkRouterPermissions); + Router.Hooks.onEnter(MetaData.init); + + Router.triggers.enter(Router.Hooks.get("onEnter", "GLOBAL")); + Router.triggers.exit(Router.Hooks.get("onExit", "GLOBAL")); +}); + + export default Router; diff --git a/client/modules/router/startup.js b/client/modules/router/startup.js index 58bf7c86c85..1b86468352d 100644 --- a/client/modules/router/startup.js +++ b/client/modules/router/startup.js @@ -12,4 +12,17 @@ Meteor.startup(function () { } } }); + + // + // we need to sometimes force + // router reload on login to get + // the entire layout to rerender + // we only do this when the routes table + // has already been generated (existing user) + // + Accounts.onLogin(() => { + if (Meteor.loggingIn() === false && Router._routes.length > 0) { + Router.reload(); + } + }); }); diff --git a/imports/plugins/core/layout/client/templates/layout/alerts/reactionAlerts.js b/imports/plugins/core/layout/client/templates/layout/alerts/reactionAlerts.js index 54f775e1562..2cfff5b03cd 100644 --- a/imports/plugins/core/layout/client/templates/layout/alerts/reactionAlerts.js +++ b/imports/plugins/core/layout/client/templates/layout/alerts/reactionAlerts.js @@ -83,20 +83,22 @@ Object.assign(Alerts, { ...options }).then((isConfirm) => { if (isConfirm === true) { - callback(isConfirm); + if (callback) { + callback(isConfirm); + } } }); }, toast(message, type, options) { switch (type) { - case "error": - case "warning": - case "success": - case "info": - return sAlert[type](message, options); - default: - return sAlert.success(message, options); + case "error": + case "warning": + case "success": + case "info": + return sAlert[type](message, options); + default: + return sAlert.success(message, options); } } }); diff --git a/imports/plugins/core/orders/client/templates/list/summary.html b/imports/plugins/core/orders/client/templates/list/summary.html index 773cb6a7813..916c37bb95f 100644 --- a/imports/plugins/core/orders/client/templates/list/summary.html +++ b/imports/plugins/core/orders/client/templates/list/summary.html @@ -26,7 +26,7 @@ {{/if}} - {{#if condition tax 'gt' 0}} + {{#if condition taxes 'gt' 0}}
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 @@
- {{> React (numericInputProps "discount" invoice.tax false)}} + {{> React (numericInputProps "discount" invoice.taxes false)}}
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 @@ + + + + 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 @@ + 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 @@ + + + + + + + + + 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 @@ + + 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 @@ + + 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 @@
- {{#autoForm schema=Schemas.ProductVariant doc=. type="method" meteormethod="products/updateVariant" id=variantFormId validation="keyup" resetOnSuccess=false}} + {{#autoForm + schema=Schemas.ProductVariant + doc=. + type="method" + meteormethod="products/updateVariant" + id=variantFormId + validation="keyup" + resetOnSuccess=false + autosave=true + }}
@@ -87,7 +96,7 @@
- {{>afFieldInput name='taxable' value=true}} + {{>afFieldInput name='taxable'}} {{#if afFieldIsInvalid name='taxable'}} {{afFieldMessage name='taxable'}} {{/if}} diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantForm/variantForm.js b/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantForm/variantForm.js index 5a077a7b0e8..033a687a878 100644 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantForm/variantForm.js +++ b/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantForm/variantForm.js @@ -39,14 +39,6 @@ Template.variantForm.helpers({ hasChildVariants: function () { return ReactionProduct.checkChildVariants(this._id) > 0; }, - hasInventoryVariants: function () { - if (!hasChildVariants()) { - return ReactionProduct.checkInventoryVariants(this._id) > 0; - } - }, - nowDate: function () { - return new Date(); - }, variantFormId: function () { return "variant-form-" + this._id; }, @@ -95,10 +87,30 @@ Template.variantForm.helpers({ Template.variantForm.events({ "change form :input": function (event, template) { - let formId; - formId = "#variant-form-" + template.data._id; - template.$(formId).submit(); - ReactionProduct.setCurrentVariant(template.data._id); + const field = $(event.currentTarget).attr("name"); + // + // this should really move into a method + // + if (field === "taxable" || field === "inventoryManagement" || field === "inventoryPolicy") { + let value = $(event.currentTarget).prop("checked"); + if (ReactionProduct.checkChildVariants(template.data._id) > 0) { + const childVariants = ReactionProduct.getVariants(template.data._id); + for (let child of childVariants) { + Meteor.call("products/updateProductField", child._id, field, value, + error => { + if (error) { + throw new Meteor.Error("error updating variant", error); + } + }); + } + } + } + + // template.$(formId).submit(); + // ReactionProduct.setCurrentVariant(template.data._id); + // + // + // }, "click .btn-child-variant-form": function (event, template) { let productId; diff --git a/imports/plugins/included/stripe/client/checkout/stripe.js b/imports/plugins/included/stripe/client/checkout/stripe.js index d6c47ff2b18..152edc743a9 100644 --- a/imports/plugins/included/stripe/client/checkout/stripe.js +++ b/imports/plugins/included/stripe/client/checkout/stripe.js @@ -30,7 +30,7 @@ function handleStripeSubmitError(error) { const singleError = error; const serverError = error ? error.message : null; if (serverError) { - return paymentAlert("Oops! " + serverError); + return paymentAlert("Oops! Credit card is invalid. Please check your information and try again."); } else if (singleError) { return paymentAlert("Oops! " + singleError); } diff --git a/imports/plugins/included/stripe/server/methods/stripe.js b/imports/plugins/included/stripe/server/methods/stripe.js index 8619cfd07b3..5dcaf9dd023 100644 --- a/imports/plugins/included/stripe/server/methods/stripe.js +++ b/imports/plugins/included/stripe/server/methods/stripe.js @@ -1,3 +1,4 @@ +import accounting from "accounting-js"; /* eslint camelcase: 0 */ // meteor modules import { Meteor } from "meteor/meteor"; @@ -45,6 +46,42 @@ function parseCardData(data) { function formatForStripe(amount) { return Math.round(amount * 100); } +function unformatFromStripe(amount) { + return (amount / 100); +} + +function stripeCaptureCharge(paymentMethod) { + let result; + const captureDetails = { + amount: formatForStripe(paymentMethod.amount) + }; + + try { + const captureResult = StripeApi.methods.captureCharge.call({ + transactionId: paymentMethod.transactionId, + captureDetails: captureDetails + }); + if (captureResult.status === "succeeded") { + result = { + saved: true, + response: captureResult + }; + } else { + result = { + saved: false, + response: captureResult + }; + } + } catch (error) { + Logger.error(error); + result = { + saved: false, + error: error + }; + return { error, result }; + } + return result; +} Meteor.methods({ @@ -108,36 +145,20 @@ Meteor.methods({ */ "stripe/payment/capture": function (paymentMethod) { check(paymentMethod, Reaction.Schemas.PaymentMethod); - let result; + // let result; const captureDetails = { amount: formatForStripe(paymentMethod.amount) }; - try { - const captureResult = StripeApi.methods.captureCharge.call({ - transactionId: paymentMethod.transactionId, - captureDetails: captureDetails - }); - if (captureResult.status === "succeeded") { - result = { - saved: true, - response: captureResult - }; - } else { - result = { - saved: false, - response: captureResult - }; - } - } catch (error) { - Logger.error(error); - result = { - saved: false, - error: error - }; - return { error, result }; + // 100% discounts are not valid when using Stripe + // If discount is 100%, capture 100% and then refund 100% of transaction + if (captureDetails.amount === accounting.unformat(0)) { + const voidedAmount = unformatFromStripe(paymentMethod.transactions[0].amount); + stripeCaptureCharge(paymentMethod); + + return Meteor.call("stripe/refund/create", paymentMethod, voidedAmount); } - return result; + return stripeCaptureCharge(paymentMethod); }, /** diff --git a/imports/plugins/included/taxes-avalara/client/index.js b/imports/plugins/included/taxes-avalara/client/index.js new file mode 100644 index 00000000000..e0102291566 --- /dev/null +++ b/imports/plugins/included/taxes-avalara/client/index.js @@ -0,0 +1,2 @@ +import "./settings/avalara.html"; +import "./settings/avalara.js"; diff --git a/imports/plugins/included/taxes-avalara/client/settings/avalara.html b/imports/plugins/included/taxes-avalara/client/settings/avalara.html new file mode 100644 index 00000000000..0f12ae34dc1 --- /dev/null +++ b/imports/plugins/included/taxes-avalara/client/settings/avalara.html @@ -0,0 +1,8 @@ + diff --git a/imports/plugins/included/taxes-avalara/client/settings/avalara.js b/imports/plugins/included/taxes-avalara/client/settings/avalara.js new file mode 100644 index 00000000000..edca34142cc --- /dev/null +++ b/imports/plugins/included/taxes-avalara/client/settings/avalara.js @@ -0,0 +1,33 @@ +import { Template } from "meteor/templating"; +import { AutoForm } from "meteor/aldeed:autoform"; +import { Reaction, i18next } from "/client/api"; +import { Packages } from "/lib/collections"; +import { AvalaraPackageConfig } from "../../lib/collections/schemas"; + + +Template.avalaraSettings.helpers({ + packageConfigSchema() { + return AvalaraPackageConfig; + }, + packageData() { + return Packages.findOne({ + name: "taxes-avalara", + shopId: Reaction.getShopId() + }); + } +}); + + +AutoForm.hooks({ + "avalara-update-form": { + onSuccess: function () { + return Alerts.toast(i18next.t("taxSettings.shopTaxMethodsSaved"), + "success"); + }, + onError: function (operation, error) { + return Alerts.toast( + `${i18next.t("taxSettings.shopTaxMethodsFailed")} ${error}`, "error" + ); + } + } +}); diff --git a/imports/plugins/included/taxes-avalara/lib/collections/schemas/index.js b/imports/plugins/included/taxes-avalara/lib/collections/schemas/index.js new file mode 100644 index 00000000000..686fbd9e9f9 --- /dev/null +++ b/imports/plugins/included/taxes-avalara/lib/collections/schemas/index.js @@ -0,0 +1 @@ +export * from "./schema"; diff --git a/imports/plugins/included/taxes-avalara/lib/collections/schemas/schema.js b/imports/plugins/included/taxes-avalara/lib/collections/schemas/schema.js new file mode 100644 index 00000000000..d50b95684e3 --- /dev/null +++ b/imports/plugins/included/taxes-avalara/lib/collections/schemas/schema.js @@ -0,0 +1,24 @@ +import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import { TaxPackageConfig } from "/imports/plugins/core/taxes/lib/collections/schemas"; + +/** +* TaxPackageConfig Schema +*/ + +export const AvalaraPackageConfig = new SimpleSchema([ + TaxPackageConfig, { + "settings.avalara": { + type: Object, + optional: true + }, + "settings.avalara.enabled": { + type: Boolean, + optional: true, + defaultValue: false + }, + "settings.avalara.apiLoginId": { + type: String, + label: "Avalara API Login ID" + } + } +]); diff --git a/imports/plugins/included/taxes-avalara/register.js b/imports/plugins/included/taxes-avalara/register.js new file mode 100644 index 00000000000..7c2cfe7e760 --- /dev/null +++ b/imports/plugins/included/taxes-avalara/register.js @@ -0,0 +1,22 @@ +import { Reaction } from "/server/api"; + +Reaction.registerPackage({ + label: "Avalara", + name: "taxes-avalara", + icon: "fa fa-university", + autoEnable: true, + settings: { + avalara: { + enabled: false, + apiLoginId: "" + } + }, + registry: [ + { + label: "Avalara", + name: "taxes/settings/avalara", + provides: "taxSettings", + template: "avalaraSettings" + } + ] +}); diff --git a/imports/plugins/included/taxes-avalara/server/hooks/hooks.js b/imports/plugins/included/taxes-avalara/server/hooks/hooks.js new file mode 100644 index 00000000000..00f9d04088c --- /dev/null +++ b/imports/plugins/included/taxes-avalara/server/hooks/hooks.js @@ -0,0 +1,94 @@ +import { Meteor } from "meteor/meteor"; +import { Logger, MethodHooks } from "/server/api"; +import { Cart, Packages } from "/lib/collections"; +import Avalara from "avalara-taxrates"; + +// +// this entire method will run after the core/taxes +// plugin runs the taxes/calculate method +// it overrwites any previous tax calculation +// tax methods precendence is determined by +// load order of plugins +// +// also note that we should address the issue +// of the alpha-3 requirement for avalara, +// and also weither we need the npm package or +// should we just use HTTP. +// +MethodHooks.after("taxes/calculate", function (options) { + let result = options.result || {}; + const cartId = options.arguments[0]; + const cartToCalc = Cart.findOne(cartId); + const shopId = cartToCalc.shopId; + + const pkg = Packages.findOne({ + name: "taxes-avalara", + shopId: shopId + }); + + // check if package is configured + if (pkg && pkg.settings.avalara) { + const apiKey = pkg.settings.avalara.apiLoginId; + + // process rate callback object + const processTaxes = Meteor.bindEnvironment(function processTaxes(res) { + if (!res.error) { + // calculate line item taxes + // maybe refactor to a core calculation + let totalTax = 0; + let taxRate = 0; + 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 * (res.totalRate / 100); + totalTax += tax; + } + } + // calc overall cart tax rate + if (totalTax > 0) { + taxRate = (totalTax / cartToCalc.cartSubTotal()); + } + // save taxRate + Meteor.call("taxes/setRate", cartId, taxRate, res.rates); + } else { + Logger.warn("Error fetching tax rate from Avalara", res.code, res.message); + } + }); + + // check if plugin is enabled and this calculation method is enabled + if (pkg && pkg.enabled === true && pkg.settings.avalara.enabled === true) { + if (!apiKey) { + Logger.warn("Avalara API Key is required."); + } + if (typeof cartToCalc.shipping !== "undefined") { + const shippingAddress = cartToCalc.shipping[0].address; + + if (shippingAddress) { + // TODO evaluate country-data + // either replace our current countries data source + // or integrate the alpha-3 codes into our dataset. + // const countries = require("country-data").countries; + const lookup = require("country-data").lookup; + // converting iso alpha 2 country to ISO 3166-1 alpha-3 + const country = lookup.countries({alpha2: shippingAddress.country})[0]; + + // get tax rate by street address + Avalara.taxByAddress(apiKey, + shippingAddress.address1, + shippingAddress.city, + shippingAddress.region, + country.alpha3, + shippingAddress.postal, + processTaxes + ); + // tax call made + Logger.info("Avalara triggered on taxes/calculate for cartId:", cartId); + } + } + } + } + // Default return value is the return value of previous call in method chain + // or an empty object if there's no result yet. + return result; +}); diff --git a/imports/plugins/included/taxes-avalara/server/hooks/index.js b/imports/plugins/included/taxes-avalara/server/hooks/index.js new file mode 100644 index 00000000000..d4957c6ce87 --- /dev/null +++ b/imports/plugins/included/taxes-avalara/server/hooks/index.js @@ -0,0 +1 @@ +import "./hooks"; diff --git a/imports/plugins/included/taxes-avalara/server/index.js b/imports/plugins/included/taxes-avalara/server/index.js new file mode 100644 index 00000000000..d4957c6ce87 --- /dev/null +++ b/imports/plugins/included/taxes-avalara/server/index.js @@ -0,0 +1 @@ +import "./hooks"; diff --git a/imports/plugins/included/taxes-taxcloud/client/index.js b/imports/plugins/included/taxes-taxcloud/client/index.js new file mode 100644 index 00000000000..da8486aad78 --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/client/index.js @@ -0,0 +1,2 @@ +import "./settings/taxcloud.html"; +import "./settings/taxcloud.js"; diff --git a/imports/plugins/included/taxes-taxcloud/client/settings/taxcloud.html b/imports/plugins/included/taxes-taxcloud/client/settings/taxcloud.html new file mode 100644 index 00000000000..db35d8ce7a7 --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/client/settings/taxcloud.html @@ -0,0 +1,11 @@ + diff --git a/imports/plugins/included/taxes-taxcloud/client/settings/taxcloud.js b/imports/plugins/included/taxes-taxcloud/client/settings/taxcloud.js new file mode 100644 index 00000000000..102df496cb8 --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/client/settings/taxcloud.js @@ -0,0 +1,32 @@ +import { Template } from "meteor/templating"; +import { AutoForm } from "meteor/aldeed:autoform"; +import { Packages } from "/lib/collections"; +import { Reaction, i18next } from "/client/api"; +import { TaxCloudPackageConfig } from "../../lib/collections/schemas"; + +Template.taxCloudSettings.helpers({ + packageConfigSchema() { + return TaxCloudPackageConfig; + }, + packageData() { + return Packages.findOne({ + name: "taxes-taxcloud", + shopId: Reaction.getShopId() + }); + } +}); + + +AutoForm.hooks({ + "taxcloud-update-form": { + onSuccess: function () { + return Alerts.toast(i18next.t("taxSettings.shopTaxMethodsSaved"), + "success"); + }, + onError: function (operation, error) { + return Alerts.toast( + `${i18next.t("taxSettings.shopTaxMethodsFailed")} ${error}`, "error" + ); + } + } +}); diff --git a/imports/plugins/included/taxes-taxcloud/lib/collections/schemas/index.js b/imports/plugins/included/taxes-taxcloud/lib/collections/schemas/index.js new file mode 100644 index 00000000000..686fbd9e9f9 --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/lib/collections/schemas/index.js @@ -0,0 +1 @@ +export * from "./schema"; diff --git a/imports/plugins/included/taxes-taxcloud/lib/collections/schemas/schema.js b/imports/plugins/included/taxes-taxcloud/lib/collections/schemas/schema.js new file mode 100644 index 00000000000..e788ed5c5f7 --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/lib/collections/schemas/schema.js @@ -0,0 +1,40 @@ +import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import { TaxPackageConfig } from "/imports/plugins/core/taxes/lib/collections/schemas"; + +/** +* TaxPackageConfig Schema +*/ + +export const TaxCloudPackageConfig = new SimpleSchema([ + TaxPackageConfig, { + "settings.taxcloud": { + type: Object, + optional: true + }, + "settings.taxcloud.enabled": { + type: Boolean, + optional: true, + defaultValue: false + }, + "settings.taxcloud.apiLoginId": { + type: String, + label: "TaxCloud API Login ID" + }, + "settings.taxcloud.apiKey": { + type: String, + label: "TaxCloud API Key" + }, + "settings.taxcloud.refreshPeriod": { + type: String, + label: "TaxCode Refresh Period", + defaultValue: "every 7 days", + optional: true + }, + "settings.taxcloud.taxCodeUrl": { + type: String, + label: "TaxCode API Url", + defaultValue: "https://taxcloud.net/tic/?format=json", + optional: true + } + } +]); diff --git a/imports/plugins/included/taxes-taxcloud/register.js b/imports/plugins/included/taxes-taxcloud/register.js new file mode 100644 index 00000000000..763fb57a73a --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/register.js @@ -0,0 +1,25 @@ +import { Reaction } from "/server/api"; + +Reaction.registerPackage({ + label: "Taxes", + name: "taxes-taxcloud", + icon: "fa fa-university", + autoEnable: true, + settings: { + taxcloud: { + enabled: false, + apiLoginId: "", + apiKey: "", + refreshPeriod: "every 7 days", + taxCodeUrl: "https://taxcloud.net/tic/?format=json" + } + }, + registry: [ + { + label: "TaxCloud", + name: "taxes/settings/taxcloud", + provides: "taxSettings", + template: "taxCloudSettings" + } + ] +}); diff --git a/imports/plugins/included/taxes-taxcloud/server/hooks/hooks.js b/imports/plugins/included/taxes-taxcloud/server/hooks/hooks.js new file mode 100644 index 00000000000..8417d901876 --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/server/hooks/hooks.js @@ -0,0 +1,125 @@ +import { Meteor } from "meteor/meteor"; +import { HTTP } from "meteor/http"; +import { Logger, MethodHooks } from "/server/api"; +import { Shops, Cart, Packages } from "/lib/collections"; + +// +// this entire method will run after the core/taxes +// plugin runs the taxes/calculate method +// it overrwites any previous tax calculation +// tax methods precendence is determined by +// load order of plugins +// +MethodHooks.after("taxes/calculate", function (options) { + let result = options.result || {}; + let origin = {}; + + const cartId = options.arguments[0]; + const cartToCalc = Cart.findOne(cartId); + const shopId = cartToCalc.shopId; + const shop = Shops.findOne(shopId); + const pkg = Packages.findOne({ + name: "taxes-taxcloud", + shopId: shopId + }); + + // check if package is configured + if (pkg && pkg.settings.taxcloud) { + const apiKey = pkg.settings.taxcloud.apiKey; + const apiLoginId = pkg.settings.taxcloud.apiLoginId; + + // get shop address + // this will need some refactoring + // for multi-vendor/shop orders + if (shop.addressBook) { + const shopAddress = shop.addressBook[0]; + origin = { + Address1: shopAddress.address1, + City: shopAddress.city, + State: shopAddress.region, + Zip5: shopAddress.postal + }; + } + + // check if plugin is enabled and this calculation method is enabled + if (pkg && pkg.enabled === true && pkg.settings.taxcloud.enabled === true) { + if (!apiKey || !apiLoginId) { + Logger.warn("TaxCloud API Key is required."); + } + if (typeof cartToCalc.shipping !== "undefined") { + const shippingAddress = cartToCalc.shipping[0].address; + + if (shippingAddress) { + Logger.info("TaxCloud triggered on taxes/calculate for cartId:", cartId); + const url = "https://api.taxcloud.net/1.0/TaxCloud/Lookup"; + const cartItems = []; + const destination = { + Address1: shippingAddress.address1, + City: shippingAddress.city, + State: shippingAddress.region, + Zip5: shippingAddress.postal + }; + + // format cart items to TaxCloud structure + let index = 0; + for (let items of cartToCalc.items) { + // only processs taxable products + if (items.variants.taxable === true) { + const item = { + Index: index, + ItemID: items.variants._id, + TIC: "00000", + Price: items.variants.price, + Qty: items.quantity + }; + index ++; + cartItems.push(item); + } + } + + // request object + const request = { + headers: { + "accept": "application/json", + "content-type": "application/json" + }, + data: { + apiKey: apiKey, + apiLoginId: apiLoginId, + customerID: cartToCalc.userId, + cartItems: cartItems, + origin: origin, + destination: destination, + cartID: cartId, + deliveredBySeller: false + } + }; + + HTTP.post(url, request, function (error, response) { + let taxRate = 0; + // ResponseType 3 is a successful call. + if (!error && response.data.ResponseType === 3) { + let totalTax = 0; + for (let item of response.data.CartItemsResponse) { + totalTax += item.TaxAmount; + } + // don't run this calculation if there isn't tax. + if (totalTax > 0) { + taxRate = (totalTax / cartToCalc.cartSubTotal()); + } + // we should consider if we want percentage and dollar + // as this is assuming that subTotal actually contains everything + // taxable + Meteor.call("taxes/setRate", cartId, taxRate, response.CartItemsResponse); + } else { + Logger.warn("Error fetching tax rate from TaxCloud:", response.data.Messages[0].Message); + } + }); + } + } + } + } + // Default return value is the return value of previous call in method chain + // or an empty object if there's no result yet. + return result; +}); diff --git a/imports/plugins/included/taxes-taxcloud/server/hooks/index.js b/imports/plugins/included/taxes-taxcloud/server/hooks/index.js new file mode 100644 index 00000000000..d4957c6ce87 --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/server/hooks/index.js @@ -0,0 +1 @@ +import "./hooks"; diff --git a/imports/plugins/included/taxes-taxcloud/server/index.js b/imports/plugins/included/taxes-taxcloud/server/index.js new file mode 100644 index 00000000000..5412a793f65 --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/server/index.js @@ -0,0 +1,7 @@ +import "./hooks"; + +// TODO decide if we want to use tax codes +// these imports was start a job to import TaxCloud taxCodes + +// import "./methods"; +// import "./jobs"; diff --git a/imports/plugins/included/taxes-taxcloud/server/jobs/index.js b/imports/plugins/included/taxes-taxcloud/server/jobs/index.js new file mode 100644 index 00000000000..9dc322893e5 --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/server/jobs/index.js @@ -0,0 +1,4 @@ +import fetchTaxCloudTaxCodes from "./taxcodes"; + +// Start "taxes/fetchTaxCloudTaxCodes" job +fetchTaxCloudTaxCodes(); diff --git a/imports/plugins/included/taxes-taxcloud/server/jobs/taxcodes.js b/imports/plugins/included/taxes-taxcloud/server/jobs/taxcodes.js new file mode 100644 index 00000000000..603e19b8185 --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/server/jobs/taxcodes.js @@ -0,0 +1,77 @@ +import { Jobs, Packages } from "/lib/collections"; +import { Hooks, Logger, Reaction } from "/server/api"; + +// +// helper to fetch reaction-taxes config +// +function getJobConfig() { + const config = Packages.findOne({ + name: "taxes-taxcloud", + shopId: Reaction.getShopId() + }); + return config.settings.taxcloud; +} + +// +// add job hook for "taxes/fetchTaxCloudTaxCodes" +// +Hooks.Events.add("afterCoreInit", () => { + const config = getJobConfig(); + const refreshPeriod = config.refreshPeriod || 0; + const taxCodeUrl = config.taxCodeUrl || "https://taxcloud.net/tic/?format=json"; + + // set 0 to disable fetchTIC + if (refreshPeriod !== 0) { + Logger.info(`Adding taxes/fetchTIC to JobControl. Refresh ${refreshPeriod}`); + new Job(Jobs, "taxes/fetchTaxCloudTaxCodes", {url: taxCodeUrl}) + .priority("normal") + .retry({ + retries: 5, + wait: 60000, + backoff: "exponential" // delay by twice as long for each subsequent retry + }) + .repeat({ + schedule: Jobs.later.parse.text(refreshPeriod) + }) + .save({ + // Cancel any jobs of the same type, + // but only if this job repeats forever. + cancelRepeats: true + }); + } +}); + +// +// index imports and +// will trigger job to run +// taxes/fetchTaxCloudTaxCodes +// +export default function () { + Jobs.processJobs( + "taxes/fetchTaxCloudTaxCodes", + { + pollInterval: 30 * 1000, + workTimeout: 180 * 1000 + }, + (job, callback) => { + Meteor.call("taxes/fetchTIC", error => { + if (error) { + if (error.error === "notConfigured") { + Logger.warn(error.message); + job.done(error.message, { repeatId: true }); + } else { + job.done(error.toString(), { repeatId: true }); + } + } else { + // we should always return "completed" job here, because errors are fine + const success = "Latest TaxCloud TaxCodes were fetched successfully."; + Reaction.Import.flush(); + Logger.info(success); + + job.done(success, { repeatId: true }); + } + }); + callback(); + } + ); +} diff --git a/imports/plugins/included/taxes-taxcloud/server/methods/methods.js b/imports/plugins/included/taxes-taxcloud/server/methods/methods.js new file mode 100644 index 00000000000..ce92cf71987 --- /dev/null +++ b/imports/plugins/included/taxes-taxcloud/server/methods/methods.js @@ -0,0 +1,57 @@ +import { Meteor } from "meteor/meteor"; +import { Match, check } from "meteor/check"; +import { HTTP } from "meteor/http"; +import { EJSON } from "meteor/ejson"; +import { Logger } from "/server/api"; +import Reaction from "../../core/taxes/server/api"; + +Meteor.methods({ + /** + * taxes/fetchTIC + * Tax Code fixture data. + * We're using https://taxcloud.net + * just to get an intial import data set + * this service doesn't require taxcloud id + * but other services need authorization + * use TAXCODE_SRC to override source url + * @param {String} url alternate url to fetch TaxCodes from + * @return {undefined} + */ + "taxes/fetchTIC": function (url) { + check(url, Match.Optional(String)); + // check(url, Match.Optional(SimpleSchema.RegEx.Url)); + + // pretty info + if (url) { + Logger.info("Fetching TaxCodes from source: ", url); + } + // allow for custom taxCodes from alternate sources + const TAXCODE_SRC = url || "https://taxcloud.net/tic/?format=json"; + const taxCodes = HTTP.get(TAXCODE_SRC); + + if (taxCodes.data && Reaction.Import.taxCode) { + for (json of taxCodes.data.tic_list) { + // transform children and flatten + // first level of tax children + // TODO: is there a need to go further + if (json.tic.children) { + const children = json.tic.children; + delete json.tic.children; // remove child levels for now + // process chilren + for (json of children) { + delete json.tic.children; // remove child levels for now + const taxCode = EJSON.stringify([json.tic]); + Reaction.Import.process(taxCode, ["id", "label"], Reaction.Import.taxCode); + } + } + // parent code process + const taxCode = EJSON.stringify([json.tic]); + Reaction.Import.process(taxCode, ["id", "label"], Reaction.Import.taxCode); + } + // commit tax records + Reaction.Import.flush(); + } else { + throw new Meteor.error("unable to load taxcodes."); + } + } +}); diff --git a/imports/plugins/included/taxes-taxjar/client/index.js b/imports/plugins/included/taxes-taxjar/client/index.js new file mode 100644 index 00000000000..0492256ca71 --- /dev/null +++ b/imports/plugins/included/taxes-taxjar/client/index.js @@ -0,0 +1,2 @@ +import "./settings/taxjar.html"; +import "./settings/taxjar.js"; diff --git a/imports/plugins/included/taxes-taxjar/client/settings/taxjar.html b/imports/plugins/included/taxes-taxjar/client/settings/taxjar.html new file mode 100644 index 00000000000..f6861c0241f --- /dev/null +++ b/imports/plugins/included/taxes-taxjar/client/settings/taxjar.html @@ -0,0 +1,8 @@ + diff --git a/imports/plugins/included/taxes-taxjar/client/settings/taxjar.js b/imports/plugins/included/taxes-taxjar/client/settings/taxjar.js new file mode 100644 index 00000000000..3a75c4376e1 --- /dev/null +++ b/imports/plugins/included/taxes-taxjar/client/settings/taxjar.js @@ -0,0 +1,32 @@ +import { Template } from "meteor/templating"; +import { AutoForm } from "meteor/aldeed:autoform"; +import { Packages } from "/lib/collections"; +import { Reaction, i18next } from "/client/api"; +import { TaxJarPackageConfig } from "../../lib/collections/schemas"; + +Template.taxJarSettings.helpers({ + packageConfigSchema() { + return TaxJarPackageConfig; + }, + packageData() { + return Packages.findOne({ + name: "taxes-taxjar", + shopId: Reaction.getShopId() + }); + } +}); + + +AutoForm.hooks({ + "taxjar-update-form": { + onSuccess: function () { + return Alerts.toast(i18next.t("taxSettings.shopTaxMethodsSaved"), + "success"); + }, + onError: function (operation, error) { + return Alerts.toast( + `${i18next.t("taxSettings.shopTaxMethodsFailed")} ${error}`, "error" + ); + } + } +}); diff --git a/imports/plugins/included/taxes-taxjar/lib/collections/schemas/index.js b/imports/plugins/included/taxes-taxjar/lib/collections/schemas/index.js new file mode 100644 index 00000000000..686fbd9e9f9 --- /dev/null +++ b/imports/plugins/included/taxes-taxjar/lib/collections/schemas/index.js @@ -0,0 +1 @@ +export * from "./schema"; diff --git a/imports/plugins/included/taxes-taxjar/lib/collections/schemas/schema.js b/imports/plugins/included/taxes-taxjar/lib/collections/schemas/schema.js new file mode 100644 index 00000000000..48e7091cc28 --- /dev/null +++ b/imports/plugins/included/taxes-taxjar/lib/collections/schemas/schema.js @@ -0,0 +1,25 @@ +import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import { TaxPackageConfig } from "/imports/plugins/core/taxes/lib/collections/schemas"; + +/** +* TaxPackageConfig Schema +*/ + +export const TaxJarPackageConfig = new SimpleSchema([ + TaxPackageConfig, { + "settings.taxjar": { + type: Object, + optional: true + }, + "settings.taxjar.enabled": { + type: Boolean, + optional: true, + defaultValue: false + }, + "settings.taxjar.apiLoginId": { + type: String, + label: "TaxJar API Login ID", + optional: true + } + } +]); diff --git a/imports/plugins/included/taxes-taxjar/register.js.disabled b/imports/plugins/included/taxes-taxjar/register.js.disabled new file mode 100644 index 00000000000..d7059c8c591 --- /dev/null +++ b/imports/plugins/included/taxes-taxjar/register.js.disabled @@ -0,0 +1,22 @@ +import { Reaction } from "/server/api"; + +Reaction.registerPackage({ + label: "Taxes", + name: "taxes-taxjar", + icon: "fa fa-university", + autoEnable: true, + settings: { + taxjar: { + enabled: false, + apiLoginId: "" + } + }, + registry: [ + { + label: "TaxJar", + name: "taxes/settings/taxjar", + provides: "taxSettings", + template: "taxJarSettings" + } + ] +}); diff --git a/imports/plugins/included/taxes-taxjar/server/hooks/hooks.js b/imports/plugins/included/taxes-taxjar/server/hooks/hooks.js new file mode 100644 index 00000000000..cfedc12df52 --- /dev/null +++ b/imports/plugins/included/taxes-taxjar/server/hooks/hooks.js @@ -0,0 +1,20 @@ +import { Reaction, Logger, MethodHooks } from "/server/api"; +import { Packages } from "/lib/collections"; + +// // Meteor.after to call after +MethodHooks.after("taxes/calculate", function (options) { + let result = options.result || {}; + const pkg = Packages.findOne({ + name: "taxes-taxjar", + shopId: Reaction.getShopId() + }); + + // check if plugin is enabled and this calculation method is enabled + if (pkg && pkg && pkg.enabled === true && pkg.settings.taxjar.enabled === true) { + Logger.warn("TaxCloud triggered on taxes/calculate for cartId:", options.arguments[0]); + } + + // Default return value is the return value of previous call in method chain + // or an empty object if there's no result yet. + return result; +}); diff --git a/imports/plugins/included/taxes-taxjar/server/hooks/index.js b/imports/plugins/included/taxes-taxjar/server/hooks/index.js new file mode 100644 index 00000000000..d4957c6ce87 --- /dev/null +++ b/imports/plugins/included/taxes-taxjar/server/hooks/index.js @@ -0,0 +1 @@ +import "./hooks"; diff --git a/imports/plugins/included/taxes-taxjar/server/index.js b/imports/plugins/included/taxes-taxjar/server/index.js new file mode 100644 index 00000000000..d4957c6ce87 --- /dev/null +++ b/imports/plugins/included/taxes-taxjar/server/index.js @@ -0,0 +1 @@ +import "./hooks"; diff --git a/lib/api/catalog.js b/lib/api/catalog.js index 5f700009bf6..de183108d1a 100644 --- a/lib/api/catalog.js +++ b/lib/api/catalog.js @@ -84,30 +84,30 @@ export default Catalog = { const children = this.getVariants(variantId); switch (children.length) { - case 0: - const topVariant = Products.findOne(variantId); - // topVariant could be undefined when we removing last top variant - return topVariant && topVariant.price; - case 1: - return children[0].price; - default: - let priceMin = Number.POSITIVE_INFINITY; - let priceMax = Number.NEGATIVE_INFINITY; + case 0: + const topVariant = Products.findOne(variantId); + // topVariant could be undefined when we removing last top variant + return topVariant && topVariant.price; + case 1: + return children[0].price; + default: + let priceMin = Number.POSITIVE_INFINITY; + let priceMax = Number.NEGATIVE_INFINITY; - children.map(child => { - if (child.price < priceMin) { - priceMin = child.price; - } - if (child.price > priceMax) { - priceMax = child.price; - } - }); + children.map(child => { + if (child.price < priceMin) { + priceMin = child.price; + } + if (child.price > priceMax) { + priceMax = child.price; + } + }); - if (priceMin === priceMax) { - // TODO check impact on i18n/formatPrice from moving return to string - return priceMin.toString(); - } - return `${priceMin} - ${priceMax}`; + if (priceMin === priceMax) { + // TODO check impact on i18n/formatPrice from moving return to string + return priceMin.toString(); + } + return `${priceMin} - ${priceMax}`; } }, diff --git a/lib/api/products.js b/lib/api/products.js index 5f577c74c65..4b3a9620525 100644 --- a/lib/api/products.js +++ b/lib/api/products.js @@ -146,11 +146,11 @@ ReactionProduct.checkChildVariants = function (parentVariantId) { * @summary return number of inventory variants for a parent * @param {String} parentVariantId - parentVariantId * @todo could be combined with checkChildVariants in one method + * @todo inventoryVariants are deprecated. remove this. * @return {Number} count of inventory variants for this parentVariantId */ ReactionProduct.checkInventoryVariants = function (parentVariantId) { - const inventoryVariants = ReactionProduct.getVariants(parentVariantId, - "inventory"); + const inventoryVariants = ReactionProduct.getVariants(parentVariantId, "inventory"); return inventoryVariants.length ? inventoryVariants.length : 0; }; diff --git a/lib/collections/collections.js b/lib/collections/collections.js index 89fa522760d..98a616e5501 100644 --- a/lib/collections/collections.js +++ b/lib/collections/collections.js @@ -129,14 +129,6 @@ export const Tags = new Mongo.Collection("Tags"); Tags.attachSchema(Schemas.Tag); -/** -* Taxes Collection -*/ -export const Taxes = new Mongo.Collection("Taxes"); - -Taxes.attachSchema(Schemas.Taxes); - - /** * Templates Collection */ diff --git a/lib/collections/helpers.js b/lib/collections/helpers.js index aed0ec2b89c..5a7b5469a01 100644 --- a/lib/collections/helpers.js +++ b/lib/collections/helpers.js @@ -48,25 +48,35 @@ export const cartTransform = { return parseFloat(getSummary(this.shipping, ["shipmentMethod", "rate"])); }, cartSubTotal() { - return getSummary(this.items, ["quantity"], ["variants", "price"]). - toFixed(2); + return getSummary(this.items, ["quantity"], ["variants", "price"]).toFixed(2); }, cartTaxes() { + // taxes are calculated in a Cart.after.update hooks + // in the imports/core/taxes plugin const tax = this.tax || 0; - return (getSummary(this.items, ["variants", "price"]) * tax).toFixed(2); + return (getSummary(this.items, ["quantity"], ["variants", "price"]) * tax).toFixed(2); }, cartDiscounts() { - return "0.00"; + // TODO add discount to schema and rules + const discount = this.discount || 0; + return discount; }, cartTotal() { let subTotal = getSummary(this.items, ["quantity"], ["variants", "price"]); - // loop through the cart.shipping, sum shipments. - let shippingTotal = getSummary(this.shipping, ["shipmentMethod", "rate"]); - shippingTotal = parseFloat(shippingTotal); - // TODO: includes taxes? + // add taxTotals + let taxTotal = parseFloat((subTotal * this.tax).toFixed(2)); + if (typeof taxTotal === "number" && taxTotal > 0) { + subTotal += taxTotal; + } + + // shipping totals + let shippingTotal = parseFloat(getSummary(this.shipping, ["shipmentMethod", "rate"])); if (typeof shippingTotal === "number" && shippingTotal > 0) { subTotal += shippingTotal; } + // + // TODO add discount cart total calculation + // return subTotal.toFixed(2); } }; diff --git a/lib/collections/schemas/cart.js b/lib/collections/schemas/cart.js index f1f2a4f8450..0243562a087 100644 --- a/lib/collections/schemas/cart.js +++ b/lib/collections/schemas/cart.js @@ -40,6 +40,10 @@ export const CartItem = new SimpleSchema({ label: "Product Type", type: String, optional: true + }, + cartItemId: { // Seems strange here but has to be here since we share schemas between cart and order + type: String, + optional: true } }); @@ -103,6 +107,16 @@ export const Cart = new SimpleSchema({ optional: true, blackbox: true }, + tax: { + type: Number, + decimal: true, + optional: true + }, + taxes: { + type: [Object], + optional: true, + blackbox: true + }, workflow: { type: Workflow, optional: true diff --git a/lib/collections/schemas/index.js b/lib/collections/schemas/index.js index 532529ab63c..17225bf75bf 100644 --- a/lib/collections/schemas/index.js +++ b/lib/collections/schemas/index.js @@ -15,7 +15,6 @@ export * from "./shipping"; export * from "./shops"; export * from "./social"; export * from "./tags"; -export * from "./taxes"; export * from "./templates"; export * from "./themes"; export * from "./translations"; diff --git a/lib/collections/schemas/products.js b/lib/collections/schemas/products.js index a87465bc800..de9cb0c9fc2 100644 --- a/lib/collections/schemas/products.js +++ b/lib/collections/schemas/products.js @@ -220,6 +220,13 @@ export const ProductVariant = new SimpleSchema({ taxable: { label: "Taxable", type: Boolean, + defaultValue: true, + optional: true + }, + taxCode: { + label: "Tax Code", + type: String, + defaultValue: "00000", optional: true }, // Label for customers diff --git a/lib/collections/schemas/taxes.js b/lib/collections/schemas/taxes.js deleted file mode 100644 index 58527225b7b..00000000000 --- a/lib/collections/schemas/taxes.js +++ /dev/null @@ -1,57 +0,0 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; -import { shopIdAutoValue } from "./helpers"; - -/** -* TaxRates Schema -*/ - -export const TaxRates = new SimpleSchema({ - country: { - type: String - }, - county: { - type: String, - optional: true - }, - rate: { - type: Number - } -}); - -export const Taxes = new SimpleSchema({ - shopId: { - type: String, - autoValue: shopIdAutoValue, - index: 1, - label: "Taxes shopId" - }, - cartMethod: { - label: "Calculation Method", - type: String, - allowedValues: ["unit", "row", "total"] - }, - taxLocale: { - label: "Taxation Location", - type: String, - allowedValues: ["shipping", "billing", "origination", "destination"] - }, - taxShipping: { - label: "Tax Shipping", - type: Boolean, - defaultValue: false - }, - taxIncluded: { - label: "Taxes included in product prices", - type: Boolean, - defaultValue: false - }, - discountsIncluded: { - label: "Tax before discounts", - type: Boolean, - defaultValue: false - }, - rates: { - label: "Tax Rate", - type: [TaxRates] - } -}); diff --git a/package.json b/package.json index 7ce68a83676..a9bb0ddfdd2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "reaction", "description": "Reaction is a modern reactive, real-time event driven ecommerce platform.", - "version": "0.14.2", + "version": "0.15.0", "main": "main.js", "directories": { "test": "tests" @@ -18,42 +18,42 @@ "bugs": { "url": "https://github.com/reactioncommerce/reaction/issues" }, - "scripts": { - "postinstall": ".reaction/scripts/postinstall.sh" - }, "dependencies": { "accounting-js": "^1.1.1", - "authorize-net": "^1.0.6", + "authorize-net": "github:ongoworks/node-authorize-net", "autonumeric": "^1.9.45", "autoprefixer": "^6.3.7", "autosize": "^3.0.17", + "avalara-taxrates": "^1.0.1", "bootstrap": "^3.3.7", "braintree": "^1.41.0", "bunyan": "^1.8.1", "bunyan-format": "^0.2.1", - "bunyan-loggly": "^1.0.0", "classnames": "^2.2.5", + "country-data": "0.0.27", "css-annotation": "^0.6.2", "faker": "^3.1.0", "fibers": "^1.0.13", "font-awesome": "^4.6.3", + "griddle-react": "^0.6.1", "i18next": "^3.4.1", - "i18next-browser-languagedetector": "^0.3.0", + "i18next-browser-languagedetector": "^1.0.0", "i18next-localstorage-cache": "^0.3.0", "i18next-sprintf-postprocessor": "^0.2.2", "jquery": "^2.2.4", - "jquery-i18next": "^0.2.0", + "jquery-i18next": "^1.0.1", "jquery-ui": "1.10.5", - "lodash": "^4.14.1", + "lodash": "^4.14.2", "meteor-node-stubs": "^0.2.3", "moment": "^2.14.1", "moment-timezone": "^0.5.4", "money": "^0.2.0", "node-geocoder": "^3.13.1", "paypal-rest-sdk": "^1.6.9", - "postcss": "^5.1.1", + "postcss": "^5.1.2", "postcss-js": "^0.1.3", "react": "^15.3.0", + "react-addons-pure-render-mixin": "^15.3.0", "react-color": "^2.2.1", "react-dom": "^15.3.0", "react-textarea-autosize": "^4.0.4", @@ -69,7 +69,6 @@ "devDependencies": { "babel-eslint": "^6.1.2", "eslint": "^2.13.1", - "eslint-plugin-meteor": "^3.6.0", "eslint-plugin-react": "^5.2.2" }, "postcss": { diff --git a/private/data/Products.json b/private/data/Products.json index 9945b55d1f3..fdd17e5c0db 100644 --- a/private/data/Products.json +++ b/private/data/Products.json @@ -54,7 +54,7 @@ "value": null }], "shopId": "J8Bhq3uTtdgwZx3rz", - "taxable": false, + "taxable": true, "type": "variant" }, { "_id": "SMr4rhDFnYvFMtDTX", @@ -80,7 +80,7 @@ "value": null }], "shopId": "J8Bhq3uTtdgwZx3rz", - "taxable": false, + "taxable": true, "type": "variant" }, { "_id": "CJoRBm9vRrorc9mxZ", @@ -106,6 +106,6 @@ "value": null }], "shopId": "J8Bhq3uTtdgwZx3rz", - "taxable": false, + "taxable": true, "type": "variant" }] diff --git a/private/data/i18n/en.json b/private/data/i18n/en.json index d853f717c84..45cf09ca914 100644 --- a/private/data/i18n/en.json +++ b/private/data/i18n/en.json @@ -62,6 +62,9 @@ "socialLabel": "Social", "socialTitle": "Social", "socialDescription": "Social Channel configuration", + "taxesLabel": "Taxes", + "taxesTitle": "Taxes", + "taxesDescription": "Tax configuration", "themesLabel": "Themes", "themesTitle": "Themes", "themesDescription": "Themes and UI Components", @@ -95,7 +98,8 @@ "paypalSettingsLabel": "PayPal Settings", "reactionConnectLabel": "Reaction Connect", "examplePaymentSettingsLabel": "Example Payment Settings", - "productSettingsLabel": "Product Settings" + "productSettingsLabel": "Product Settings", + "taxSettingsLabel": "Tax Settings" }, "userAccountDropdown": { "profileLabel": "Profile" @@ -150,19 +154,34 @@ "paymentMethods": "Payment Methods", "externalServices": "External Services", "shopGeneralSettingsSaved": "Shop general settings saved.", - "shopGeneralSettingsFailed": "Shop general settings update failed. ", + "shopGeneralSettingsFailed": "Shop general settings update failed.", "shopAddressSettingsSaved": "Shop address settings saved.", - "shopAddressSettingsFailed": "Shop address settings update failed. ", + "shopAddressSettingsFailed": "Shop address settings update failed.", "shopMailSettingsSaved": "Shop mail settings saved.", - "shopMailSettingsFailed": "Shop mail settings update failed. ", + "shopMailSettingsFailed": "Shop mail settings update failed.", "shopExternalServicesSettingsSaved": "Shop external services settings saved.", - "shopExternalServicesSettingsFailed": "Shop external services settings update failed. ", + "shopExternalServicesSettingsFailed": "Shop external services settings update failed.", "shopLocalizationSettingsSaved": "Shop localization settings saved.", - "shopLocalizationSettingsFailed": "Shop localization settings update failed. ", + "shopLocalizationSettingsFailed": "Shop localization settings update failed.", "shopOptionsSettingsSaved": "Shop options saved.", - "shopOptionsSettingsFailed": "Shop options update failed. ", + "shopOptionsSettingsFailed": "Shop options update failed.", "shopPaymentMethodsSaved": "Shop Payment Methods settings saved.", - "shopPaymentMethodsFailed": "Shop Payment Methods settings update failed. " + "shopPaymentMethodsFailed": "Shop Payment Methods settings update failed." + }, + "taxSettings": { + "noCustomTaxRatesFound": "No custom tax rates found.", + "shopCustomTaxRatesSaved": "Custom tax rate saved.", + "shopCustomTaxRatesFailed": "Error saving tax rate.", + "shopTaxMethodsSaved": "Saved tax method.", + "shopTaxMethodsFailed": "Failed saving tax method.", + "customRatesDescription": "Custom Rates", + "taxCode": "Tax Code", + "taxShipping": "Include Shipping", + "taxRate": "Rate", + "discountsIncluded": "Include Discounts", + "taxable": "Taxable", + "nottaxable": "Not Taxable", + "confirmRateDelete": "Confirm tax rate deletion" }, "header": { "tagsAdd": "Add tag", @@ -340,6 +359,11 @@ "completed": "Completed", "canceled": "Canceled", "refunded": "Refunded" + }, + "paymentProvider": { + "braintree": { + "braintreeSettlementDelay": "Braintree does not allow refunds until transactions are settled. This can take up to 24 hours. Please try again later." + } } }, "orderShipping": { diff --git a/private/email/templates/accounts/verify_email.html b/private/email/templates/accounts/verify_email.html new file mode 100644 index 00000000000..218eb9a6b72 --- /dev/null +++ b/private/email/templates/accounts/verify_email.html @@ -0,0 +1,270 @@ + + + + + Reaction Commerce + + + + + + + + + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
 
Hello,
 
Congratulations! Your Reaction shop is ready to go!
 
+ + + + +
+ + + + + + + + +
VERIFY YOUR EMAIL AND ACCESS YOUR SHOP
Your login email: {{email}}
+
+
 
Go ahead, jump on in! You can start adding products, setting up your payment provider and shipping details, and much more! In case you get stuck, we have some helpful docs to get you unstuck, and we’re here to help.
 
We’d love your feedback and any thoughts you have as you explore your shop. You can email us at any time, submit comments, or file an issue. +
 
+ Thanks! +
+ Reaction Team +
+ + @getreaction +
 
+ + + + + + + + + + +
SupportbrDocsbrBlogbrDeveloper Chat
+ +
+ + + Twitter + + + + Facebook + + + + Instagram + + + + + GitHub + +
 
Copyright © 2016, Reaction Commerce™
 
+
+
+ + diff --git a/private/email/templates/coreDefault.html b/private/email/templates/coreDefault.html index e69de29bb2d..04331b7dc1c 100644 --- a/private/email/templates/coreDefault.html +++ b/private/email/templates/coreDefault.html @@ -0,0 +1,3 @@ + + This is the placeholder template at private/email/templates/coreDefault.html + diff --git a/private/email/templates/orders/coreOrderWorkflow/completed.html b/private/email/templates/orders/coreOrderWorkflow/completed.html new file mode 100644 index 00000000000..7bec6c80529 --- /dev/null +++ b/private/email/templates/orders/coreOrderWorkflow/completed.html @@ -0,0 +1,899 @@ + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + +
+ + + + + + + +
+ {{shop.name}} +
+ +
+
+
+
+ + + + + +
+ + + + + + +
+ + + + + +
+

Hi, {{user.username}}

+
+ + + + + + +
+

Your {{shop.name}} order has been completed.

+
+ +
+ + + + + +
+ + + + + + +
+

Your tracking information is {{shipment.tracking}}

+
+
+ + + + + +


+ + + + +
+ + + + + + +
+
+

Sent by {{shop.name}}

+
+
+ +
+ +
+
+
+ diff --git a/public/.gitignore b/public/.gitignore index 28cfe53eceb..e69de29bb2d 100644 --- a/public/.gitignore +++ b/public/.gitignore @@ -1,2 +0,0 @@ -fonts/fontawesome-* -fonts/FontAwesome* diff --git a/public/fonts/FontAwesome.otf b/public/fonts/FontAwesome.otf new file mode 100644 index 00000000000..d4de13e832d Binary files /dev/null and b/public/fonts/FontAwesome.otf differ diff --git a/public/fonts/fontawesome-webfont.eot b/public/fonts/fontawesome-webfont.eot new file mode 100644 index 00000000000..c7b00d2ba88 Binary files /dev/null and b/public/fonts/fontawesome-webfont.eot differ diff --git a/public/fonts/fontawesome-webfont.svg b/public/fonts/fontawesome-webfont.svg new file mode 100644 index 00000000000..8b66187fe06 --- /dev/null +++ b/public/fonts/fontawesome-webfont.svg @@ -0,0 +1,685 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/fonts/fontawesome-webfont.ttf b/public/fonts/fontawesome-webfont.ttf new file mode 100644 index 00000000000..f221e50a2ef Binary files /dev/null and b/public/fonts/fontawesome-webfont.ttf differ diff --git a/public/fonts/fontawesome-webfont.woff b/public/fonts/fontawesome-webfont.woff new file mode 100644 index 00000000000..6e7483cf61b Binary files /dev/null and b/public/fonts/fontawesome-webfont.woff differ diff --git a/public/fonts/fontawesome-webfont.woff2 b/public/fonts/fontawesome-webfont.woff2 new file mode 100644 index 00000000000..7eb74fd127e Binary files /dev/null and b/public/fonts/fontawesome-webfont.woff2 differ diff --git a/server/api/core/core.js b/server/api/core/core.js index d3a408f90a0..2aedc2ba008 100644 --- a/server/api/core/core.js +++ b/server/api/core/core.js @@ -4,6 +4,7 @@ import { Meteor } from "meteor/meteor"; import { EJSON } from "meteor/ejson"; import { Jobs, Packages, Shops } from "/lib/collections"; import { Hooks, Logger } from "/server/api"; +import ProcessJobs from "/server/jobs"; import { getRegistryDomain } from "./setDomain"; export default { @@ -14,6 +15,7 @@ export default { // start job server Jobs.startJobServer(() => { Logger.info("JobServer started"); + ProcessJobs(); Hooks.Events.run("onJobServerStart"); }); if (process.env.VERBOSE_JOBS) { @@ -47,7 +49,7 @@ export default { * server permissions checks * hasPermission exists on both the server and the client. * @param {String | Array} checkPermissions -String or Array of permissions if empty, defaults to "admin, owner" - * @param {String} checkUserId - userId, defaults to Meteor.userId() + * @param {String} userId - userId, defaults to Meteor.userId() * @param {String} checkGroup group - default to shopId * @return {Boolean} Boolean - true if has permission */ @@ -154,10 +156,8 @@ export default { return mailUrl; } // return reasonable warning that we're not configured correctly - if (!process.env.MAIL_URL) { - Logger.warn("Mail server not configured. Unable to send email."); - return false; - } + Logger.warn("Mail server not configured. Unable to send email."); + return false; }, getCurrentShopCursor() { @@ -205,6 +205,14 @@ export default { return shop && shop.name; }, + getShopEmail() { + const shop = Shops.find({ _id: this.getShopId() }, { + limit: 1, + fields: { emails: 1 } + }).fetch()[0]; + return shop && shop.emails && shop.emails[0].address; + }, + /** * createDefaultAdminUser * @summary Method that creates default admin user @@ -300,12 +308,11 @@ export default { }); } else { // send verification email to admin try { - // if server is not confgured. Error in configuration + // if server is not configured. Error in configuration // are caught, but admin isn't verified. Accounts.sendVerificationEmail(accountId); } catch (error) { - Logger.warn( - "Unable to send admin account verification email.", error); + Logger.warn(error, "Unable to send admin account verification email."); } } @@ -422,7 +429,7 @@ export default { } // Import package data this.Import.package(combinedSettings, shopId); - Logger.info(`Initializing ${shop.name} ${pkgName}`); + return Logger.info(`Initializing ${shop.name} ${pkgName}`); }); // end shops }); @@ -446,6 +453,7 @@ export default { name: pkg.name }); } + return false; }); }); } diff --git a/server/api/core/email.js b/server/api/core/email.js new file mode 100644 index 00000000000..c1305dbb75d --- /dev/null +++ b/server/api/core/email.js @@ -0,0 +1,61 @@ +import { Meteor } from "meteor/meteor"; +import { check } from "meteor/check"; +import { Job } from "meteor/vsivsi:job-collection"; +import { Jobs, Packages, Templates } from "/lib/collections"; +import { Logger } from "/server/api"; + + +/** + * Reaction.Email.send() + * (Job API doc) https://github.com/vsivsi/meteor-job-collection/#user-content-job-api + * @param {Object} options - object containing to/from/subject/html String keys + * @return {Boolean} returns job object + */ +export function send(options) { + return new Job(Jobs, "sendEmail", options) + .retry({ + retries: 5, + wait: 3 * 60000 + }).save(); +} + + +/** + * Reaction.Email.getTemplate() - Returns a template source for SSR consumption + * layout must be defined + template + * @param {String} template name of the template in either Layouts or fs + * @returns {Object} returns source + */ +export function getTemplate(template) { + check(template, String); + + const language = "en"; + + const shopLocale = Meteor.call("shop/getLocale"); + + if (shopLocale && shopLocale.locale && shopLocale.locale.languages) { + lang = shopLocale.locale.languages; + } + + // using layout where in the future a more comprehensive rule based + // filter of the email templates can be implemented. + const tpl = Packages.findOne({ + "layout.template": template + }); + + if (tpl) { + const tplSource = Templates.findOne({ template, language }); + if (tplSource.source) { + return tplSource.source; + } + } + + const file = `email/templates/${template}.html`; + + try { + return Assets.getText(file); + } catch (e) { + Logger.warn(`Template not found: ${file}. Falling back to coreDefault.html`); + return Assets.getText("email/templates/coreDefault.html"); + } +} diff --git a/server/api/core/import.js b/server/api/core/import.js index 786385d4eac..5d1990ceeaf 100644 --- a/server/api/core/import.js +++ b/server/api/core/import.js @@ -292,8 +292,8 @@ Import.layout = function (layout, shopId) { _id: shopId }; return this.object(Collections.Shops, key, { - "_id": shopId, - "layout": layout + _id: shopId, + layout: layout }); }; @@ -329,33 +329,32 @@ Import.tag = function (key, tag) { * from the rightSet. But only those, that were not present * in the leftSet. */ -function doRightJoinNoIntersection (leftSet, rightSet) { +function doRightJoinNoIntersection(leftSet, rightSet) { if (rightSet === null) return null; let rightJoin; if (Array.isArray(rightSet)) { - rightJoin = []; + rightJoin = []; } else { - rightJoin = {}; + rightJoin = {}; } let findRightOnlyProperties = function () { - return Object.keys(rightSet).filter(function(key) { + return Object.keys(rightSet).filter(function (key) { if (typeof(rightSet[key]) === "object" && - !Array.isArray(rightSet[key])) { - // Nested objects are always considered - return true; - } else { - // Array or primitive value - return !leftSet.hasOwnProperty(key); + !Array.isArray(rightSet[key])) { + // Nested objects are always considered + return true; } - }) + // Array or primitive value + return !leftSet.hasOwnProperty(key); + }); }; - for (let key of findRightOnlyProperties()){ + for (let key of findRightOnlyProperties()) { if (typeof(rightSet[key]) === "object") { // subobject or array if (leftSet.hasOwnProperty(key) && (typeof(leftSet[key]) !== "object" || - Array.isArray(leftSet[key])!== Array.isArray(rightSet[key]))) { + Array.isArray(leftSet[key]) !== Array.isArray(rightSet[ key ]))) { // This is not expected! throw new Error( "Left object and right object's internal structure must be " + @@ -368,7 +367,7 @@ function doRightJoinNoIntersection (leftSet, rightSet) { ); let obj = {}; - if (rightSubJoin === null){ + if (rightSubJoin === null) { obj[key] = null; } else if (Object.keys(rightSubJoin).length !== 0 || Array.isArray(rightSubJoin)) { @@ -423,7 +422,7 @@ Import.object = function (collection, key, object) { // Upsert the object. let find = this.buffer(collection).find(key); - if (Object.keys(defaultValuesObject).length === 0){ + if (Object.keys(defaultValuesObject).length === 0) { find.upsert().update({ $set: importObject }); diff --git a/server/api/core/index.js b/server/api/core/index.js index 388e157b00c..d3e318f4561 100644 --- a/server/api/core/index.js +++ b/server/api/core/index.js @@ -1,5 +1,6 @@ import Core from "./core"; import * as AssignRoles from "./assignRoles"; +import * as Email from "./email"; import Import from "./import"; import * as LoadSettings from "./loadSettings"; import Log from "../logger"; @@ -19,6 +20,7 @@ const Reaction = Object.assign( Core, AssignRoles, { Collections }, + { Email }, { Import }, LoadSettings, { Log }, diff --git a/server/api/email.js b/server/api/email.js index 254ded8af89..b9e167c1a4d 100644 --- a/server/api/email.js +++ b/server/api/email.js @@ -1,42 +1,35 @@ -import { Packages, Templates } from "/lib/collections"; +import urlParser from "url"; +import { Accounts } from "meteor/accounts-base"; +import { SSR } from "meteor/meteorhacks:ssr"; +import { Reaction, Logger } from "/server/api"; + +const shopName = Reaction.getShopName() || "Reaction"; +const shopEmail = Reaction.getShopEmail() || "hello@reactioncommerce.com"; /** - * ReactionEmailTemplate - Returns a template source for SSR consumption - * layout must be defined + template - * @param {String} template name of the template in either Layouts or fs - * @returns {Object} returns source + * Accounts Email Configs */ -ReactionEmailTemplate = function (template) { - check(template, String); - let source; - let lang = "en"; +Accounts.emailTemplates.siteName = shopName; +Accounts.emailTemplates.from = `${shopName} <${shopEmail}>`; - const shopLocale = Meteor.call("shop/getLocale"); +Accounts.emailTemplates.verifyEmail.subject = () => { + return "Your account is almost ready! Just one more step..."; +}; - if (shopLocale && shopLocale.locale && shopLocale.locale.languages) { - lang = shopLocale.locale.languages; +// render the custom email verification template +Accounts.emailTemplates.verifyEmail.html = (user, url) => { + let emailTemplate; + try { + emailTemplate = Assets.getText("email/templates/accounts/verify_email.html"); + } catch (e) { + Logger.error(e); + throw new Error(e); } - // using layout where in the future a more comprehensive rule based - // filter of the email templates can be implemented. - const tpl = Packages.findOne({ - "layout.template": template - }); + SSR.compileTemplate("verify-email", emailTemplate); - if (tpl) { - const tplSource = Templates.findOne({ - template: template, - language: lang - }); - if (tplSource.source) { - return tplSource.source; - } - } - let file = `email/templates/${template}.html`; - try { - source = Assets.getText(file); - } catch (e) { // default blank template - source = Assets.getText("email/templates/coreDefault.html"); - } - return source; + const domain = urlParser.parse(url).hostname; + const email = user.emails[0].address; + + return SSR.render("verify-email", { url, domain, email }); }; diff --git a/server/imports/fixtures/fixtures.app-test.js b/server/imports/fixtures/fixtures.app-test.js index 8929a540fa1..f8bcc51ea97 100644 --- a/server/imports/fixtures/fixtures.app-test.js +++ b/server/imports/fixtures/fixtures.app-test.js @@ -44,6 +44,9 @@ describe("Fixtures:", function () { sandbox.stub(Meteor.server.method_handlers, "inventory/register", function () { check(arguments, [Match.Any]); }); + sandbox.stub(Meteor.server.method_handlers, "inventory/sold", function () { + check(arguments, [Match.Any]); + }); const order = Factory.create("order"); expect(order).to.not.be.undefined; const orderCount = Collections.Orders.find().count(); diff --git a/server/jobs/email.js b/server/jobs/email.js new file mode 100644 index 00000000000..a1e913927e7 --- /dev/null +++ b/server/jobs/email.js @@ -0,0 +1,55 @@ +import { Email } from "meteor/email"; +import { Job } from "meteor/vsivsi:job-collection"; +import { Jobs } from "/lib/collections"; +import { Reaction, Logger } from "/server/api"; + +export default function () { + /** + * Send Email job + * + * Example usage: + * new Job(Jobs, "sendEmail", { from, to, subject, html }).save(); + */ + const sendEmail = Job.processJobs(Jobs, "sendEmail", { + pollInterval: 5 * 60 * 1000, // poll every 5 mins as a backup - see the realtime observer below + workTimeout: 20000, // fail if it takes longer than 20secs + payload: 10 + }, (jobs, callback) => { + jobs.forEach((job) => { + const { from, to, subject, html } = job.data; + + if (!from || !to || !subject || !html) { + const msg = "Email job requires an options object with to/from/subject/html."; + Logger.error(`[Job]: ${msg}`); + return job.fail(msg); + } + + if (!Reaction.configureMailUrl()) { + return job.fail("Mail not configured"); + } + + try { + Email.send({ from, to, subject, html }); + Logger.info(`Successfully sent email to ${to.substring(to.indexOf("@") + 1)}`); + } catch (error) { + Logger.error(error, "Email job failed"); + return job.fail(error.toString()); + } + + return job.done(); + }); + + return callback(); + }); + + // Job Collection Observer + // This processes an email sending job as soon as it's submitted + Jobs.find({ + type: "sendEmail", + status: "ready" + }).observe({ + added() { + sendEmail.trigger(); + } + }); +} diff --git a/server/jobs/index.js b/server/jobs/index.js new file mode 100644 index 00000000000..d8897c5f7c1 --- /dev/null +++ b/server/jobs/index.js @@ -0,0 +1,5 @@ +import email from "./email"; + +export default function () { + email(); +} diff --git a/server/methods/accounts/accounts.js b/server/methods/accounts/accounts.js index 9c7c415a6b6..da9140c9a55 100644 --- a/server/methods/accounts/accounts.js +++ b/server/methods/accounts/accounts.js @@ -11,8 +11,7 @@ Meteor.methods({ * check if current user has password */ "accounts/currentUserHasPassword": function () { - let user; - user = Meteor.users.findOne(Meteor.userId()); + const user = Meteor.users.findOne(Meteor.userId()); if (user.services.password) { return true; } @@ -238,101 +237,91 @@ Meteor.methods({ * @returns {Boolean} returns true */ "accounts/inviteShopMember": function (shopId, email, name) { - let currentUserName; - let shop; - let token; - let user; - let userId; check(shopId, String); check(email, String); check(name, String); + this.unblock(); - shop = Collections.Shops.findOne(shopId); - if (!Reaction.hasPermission("reaction-accounts", Meteor.userId(), shopId)) { - throw new Meteor.Error(403, "Access denied"); + const shop = Collections.Shops.findOne(shopId); + + if (!shop) { + const msg = `accounts/inviteShopMember - Shop ${shopId} not found`; + Logger.error(msg); + throw new Meteor.Error("shop-not-found", msg); } - Reaction.configureMailUrl(); - // don't send account emails unless email server configured - if (!process.env.MAIL_URL) { - Logger.info("Mail not configured: suppressing invite email output"); - return true; + if (!Reaction.hasPermission("reaction-accounts", this.userId, shopId)) { + Logger.error(`User ${this.userId} does not have reaction-accounts permissions`); + throw new Meteor.Error("access-denied", "Access denied"); } - // everything cool? invite user - if (shop && email && name) { - let currentUser = Meteor.user(); - if (currentUser) { - if (currentUser.profile) { - currentUserName = currentUser.profile.name; - } else { - currentUserName = currentUser.username; - } + + const currentUser = Meteor.users.findOne(this.userId); + + let currentUserName; + + if (currentUser) { + if (currentUser.profile) { + currentUserName = currentUser.profile.name || currentUser.username; } else { - currentUserName = "Admin"; + currentUserName = currentUser.username; } + } else { + currentUserName = "Admin"; + } - user = Meteor.users.findOne({ - "emails.address": email + const user = Meteor.users.findOne({ + "emails.address": email + }); + + const tmpl = "accounts/inviteShopMember"; + SSR.compileTemplate("accounts/inviteShopMember", Reaction.Email.getTemplate(tmpl)); + + if (!user) { + const userId = Accounts.createUser({ + email: email, + username: name }); - if (!user) { - userId = Accounts.createUser({ - email: email, - username: name - }); - user = Meteor.users.findOne(userId); - if (!user) { - throw new Error("Can't find user"); - } - token = Random.id(); - Meteor.users.update(userId, { - $set: { - "services.password.reset": { - token: token, - email: email, - when: new Date() - } - } - }); - SSR.compileTemplate("accounts/inviteShopMember", ReactionEmailTemplate("accounts/inviteShopMember")); - try { - return Email.send({ - to: email, - from: `${shop.name} <${shop.emails[0].address}>`, - subject: `You have been invited to join ${shop.name}`, - html: SSR.render("accounts/inviteShopMember", { - homepage: Meteor.absoluteUrl(), - shop: shop, - currentUserName: currentUserName, - invitedUserName: name, - url: Accounts.urls.enrollAccount(token) - }) - }); - } catch (_error) { - throw new Meteor.Error(403, "Unable to send invitation email."); - } - } else { - SSR.compileTemplate("accounts/inviteShopMember", ReactionEmailTemplate("accounts/inviteShopMember")); - try { - return Email.send({ - to: email, - from: `${shop.name} <${shop.emails[0].address}>`, - subject: `You have been invited to join the ${shop.name}`, - html: SSR.render("accounts/inviteShopMember", { - homepage: Meteor.absoluteUrl(), - shop: shop, - currentUserName: currentUserName, - invitedUserName: name, - url: Meteor.absoluteUrl() - }) - }); - } catch (_error) { - throw new Meteor.Error(403, "Unable to send invitation email."); - } + const newUser = Meteor.users.findOne(userId); + + if (!newUser) { + throw new Error("Can't find user"); } + + const token = Random.id(); + + Meteor.users.update(userId, { + $set: { + "services.password.reset": { token, email, when: new Date() } + } + }); + + Reaction.Email.send({ + to: email, + from: `${shop.name} <${shop.emails[0].address}>`, + subject: `You have been invited to join ${shop.name}`, + html: SSR.render("accounts/inviteShopMember", { + homepage: Meteor.absoluteUrl(), + shop, + currentUserName, + invitedUserName: name, + url: Accounts.urls.enrollAccount(token) + }) + }); } else { - throw new Meteor.Error(403, "Access denied"); + Reaction.Email.send({ + to: email, + from: `${shop.name} <${shop.emails[0].address}>`, + subject: `You have been invited to join ${shop.name}`, + html: SSR.render("accounts/inviteShopMember", { + homepage: Meteor.absoluteUrl(), + shop, + currentUserName, + invitedUserName: name, + url: Meteor.absoluteUrl() + }) + }); } return true; }, @@ -347,18 +336,20 @@ Meteor.methods({ "accounts/sendWelcomeEmail": function (shopId, userId) { check(shopId, String); check(userId, String); + this.unblock(); + const user = Collections.Accounts.findOne(userId); const shop = Collections.Shops.findOne(shopId); - let shopEmail; // anonymous users arent welcome here if (!user.emails || !user.emails.length > 0) { return true; } - let userEmail = user.emails[0].address; + const userEmail = user.emails[0].address; + let shopEmail; // provide some defaults for missing shop email. if (!shop.emails) { shopEmail = `${shop.name}@localhost`; @@ -367,30 +358,23 @@ Meteor.methods({ shopEmail = shop.emails[0].address; } - // configure email - Reaction.configureMailUrl(); - // don't send account emails unless email server configured - if (!process.env.MAIL_URL) { - Logger.info("Mail not configured: suppressing welcome email output"); - return true; - } - // fetch and send templates - SSR.compileTemplate("accounts/sendWelcomeEmail", ReactionEmailTemplate("accounts/sendWelcomeEmail")); - try { - return Email.send({ - to: userEmail, - from: `${shop.name} <${shopEmail}>`, - subject: `Welcome to ${shop.name}!`, - html: SSR.render("accounts/sendWelcomeEmail", { - homepage: Meteor.absoluteUrl(), - shop: shop, - user: Meteor.user() - }) - }); - } catch (e) { - Logger.warn("Unable to send email, check configuration and port.", e); - } + const tmpl = "accounts/sendWelcomeEmail"; + SSR.compileTemplate("accounts/sendWelcomeEmail", Reaction.Email.getTemplate(tmpl)); + + Reaction.Email.send({ + to: userEmail, + from: `${shop.name} <${shopEmail}>`, + subject: `Welcome to ${shop.name}!`, + html: SSR.render("accounts/sendWelcomeEmail", { + homepage: Meteor.absoluteUrl(), + shop: shop, + user: Meteor.user() + }) + }); + + return true; }, + /** * accounts/addUserPermissions * @param {String} userId - userId @@ -413,7 +397,7 @@ Meteor.methods({ try { return Roles.addUsersToRoles(userId, permissions, group); } catch (error) { - return Logger.info(error); + return Logger.error(error); } }, @@ -432,7 +416,7 @@ Meteor.methods({ try { return Roles.removeUsersFromRoles(userId, permissions, group); } catch (error) { - Logger.info(error); + Logger.error(error); throw new Meteor.Error(403, "Access Denied"); } }, @@ -455,7 +439,7 @@ Meteor.methods({ try { return Roles.setUserRoles(userId, permissions, group); } catch (error) { - Logger.info(error); + Logger.error(error); return error; } } diff --git a/server/methods/catalog.app-test.js b/server/methods/catalog.app-test.js index 7009c4c897d..42a6e41a556 100644 --- a/server/methods/catalog.app-test.js +++ b/server/methods/catalog.app-test.js @@ -149,6 +149,7 @@ describe("core product methods", function () { expect(variants.length).to.equal(1); Meteor.call("products/createVariant", product._id, newVariant); + Meteor._sleepForMs(500); variants = Products.find({ ancestors: [product._id] }).fetch(); const createdVariant = variants.filter(v => v._id !== firstVariantId); expect(variants.length).to.equal(2); @@ -613,6 +614,7 @@ describe("core product methods", function () { Meteor.call("products/updateVariantsPosition", [ product2._id, product3._id, product._id ]); + Meteor._sleepForMs(500); const modifiedProduct = Products.findOne(product._id); const modifiedProduct2 = Products.findOne(product2._id); const modifiedProduct3 = Products.findOne(product3._id); diff --git a/server/methods/catalog.js b/server/methods/catalog.js index 85f11512199..070ddfa7cd0 100644 --- a/server/methods/catalog.js +++ b/server/methods/catalog.js @@ -191,25 +191,25 @@ function denormalize(id, field) { let update = {}; switch (field) { - case "inventoryPolicy": - case "inventoryQuantity": - case "inventoryManagement": - Object.assign(update, { - isSoldOut: isSoldOut(variants), - isLowQuantity: isLowQuantity(variants), - isBackorder: isBackorder(variants) - }); - break; - case "lowInventoryWarningThreshold": - Object.assign(update, { - isLowQuantity: isLowQuantity(variants) - }); - break; - default: // "price" is object with range, min, max - const priceObject = Catalog.getProductPriceRange(id); - Object.assign(update, { - price: priceObject - }); + case "inventoryPolicy": + case "inventoryQuantity": + case "inventoryManagement": + Object.assign(update, { + isSoldOut: isSoldOut(variants), + isLowQuantity: isLowQuantity(variants), + isBackorder: isBackorder(variants) + }); + break; + case "lowInventoryWarningThreshold": + Object.assign(update, { + isLowQuantity: isLowQuantity(variants) + }); + break; + default: // "price" is object with range, min, max + const priceObject = Catalog.getProductPriceRange(id); + Object.assign(update, { + price: priceObject + }); } Products.update(id, { $set: update @@ -700,7 +700,7 @@ Meteor.methods({ "products/deleteProduct": function (productId) { check(productId, Match.OneOf(Array, String)); // must have admin permission to delete - if (!Reaction.hasAdminAccess()) { + if (!Reaction.hasPermission("createProduct") && !Reaction.hasAdminAccess()) { throw new Meteor.Error(403, "Access Denied"); } @@ -777,11 +777,17 @@ Meteor.methods({ const doc = Products.findOne(_id); const type = doc.type; - let stringValue = EJSON.stringify(value); - let update = EJSON.parse("{\"" + field + "\":" + stringValue + "}"); + let update; + // handle booleans with correct typing + if (value === "false" || value === "true") { + update = EJSON.parse(`{${field}:${value}}`); + } else { + let stringValue = EJSON.stringify(value); + update = EJSON.parse("{\"" + field + "\":" + stringValue + "}"); + } // we need to use sync mode here, to return correct error and result to UI - const result = Products.update(_id, { + let result = Products.update(_id, { $set: update }, { selector: { @@ -794,7 +800,6 @@ Meteor.methods({ denormalize(doc.ancestors[0], field); } } - return result; }, @@ -822,7 +827,7 @@ Meteor.methods({ }; let existingTag = Tags.findOne({ - name: tagName + slug: Reaction.getSlug(tagName) }); if (existingTag) { diff --git a/server/methods/core/cart-remove.app-test.js b/server/methods/core/cart-remove.app-test.js index 2d72d57e0e8..75ff12d0b52 100644 --- a/server/methods/core/cart-remove.app-test.js +++ b/server/methods/core/cart-remove.app-test.js @@ -54,6 +54,46 @@ describe("cart methods", function () { return done(); }); + it("when called with a quantity, should decrease the quantity", function () { + sandbox.stub(Meteor.server.method_handlers, "cart/resetShipmentMethod", function () { + check(arguments, [Match.Any]); + }); + sandbox.stub(Meteor.server.method_handlers, "shipping/updateShipmentQuotes", function () { + check(arguments, [Match.Any]); + }); + let cart = Factory.create("cart"); + const cartUserId = cart.userId; + sandbox.stub(Reaction, "getShopId", () => shop._id); + sandbox.stub(Meteor, "userId", () => cartUserId); + let cartFromCollection = Collections.Cart.findOne(cart._id); + const cartItemId = cartFromCollection.items[0]._id; + const originalQty = cartFromCollection.items[0].quantity; + Meteor.call("cart/removeFromCart", cartItemId, 1); + Meteor._sleepForMs(500); + let updatedCart = Collections.Cart.findOne(cart._id); + expect(updatedCart.items[0].quantity).to.equal(originalQty - 1); + }); + + it("when quantity is decresed to zero, remove cart item", function () { + sandbox.stub(Meteor.server.method_handlers, "cart/resetShipmentMethod", function () { + check(arguments, [Match.Any]); + }); + sandbox.stub(Meteor.server.method_handlers, "shipping/updateShipmentQuotes", function () { + check(arguments, [Match.Any]); + }); + let cart = Factory.create("cart"); + const cartUserId = cart.userId; + sandbox.stub(Reaction, "getShopId", () => shop._id); + sandbox.stub(Meteor, "userId", () => cartUserId); + let cartFromCollection = Collections.Cart.findOne(cart._id); + const cartItemId = cartFromCollection.items[0]._id; + const originalQty = cartFromCollection.items[0].quantity; + Meteor.call("cart/removeFromCart", cartItemId, originalQty); + Meteor._sleepForMs(500); + let updatedCart = Collections.Cart.findOne(cart._id); + expect(updatedCart.items.length).to.equal(1); + }); + it("should throw an exception when attempting to remove item from cart of another user", function (done) { const cart = Factory.create("cart"); const cartItemId = "testId123"; @@ -65,7 +105,7 @@ describe("cart methods", function () { function removeFromCartFunc() { return Meteor.call("cart/removeFromCart", cartItemId); } - expect(removeFromCartFunc).to.throw(Meteor.Error, /item not found/); + expect(removeFromCartFunc).to.throw(Meteor.Error, /cart-item-not-found/); return done(); }); @@ -79,7 +119,7 @@ describe("cart methods", function () { function removeFromCartFunc() { return Meteor.call("cart/removeFromCart", cartItemId); } - expect(removeFromCartFunc).to.throw(Meteor.Error, /item not found/); + expect(removeFromCartFunc).to.throw(Meteor.Error, /cart-item-not-found/); return done(); }); }); diff --git a/server/methods/core/cart.js b/server/methods/core/cart.js index 26e652f448d..4c7b23a203e 100644 --- a/server/methods/core/cart.js +++ b/server/methods/core/cart.js @@ -29,14 +29,14 @@ function quantityProcessing(product, variant, itemQty = 1) { // TODO: think about #152 implementation here switch (product.type) { - case "not-in-stock": - break; - default: // type: `simple` // todo: maybe it should be "variant" - if (quantity < MIN) { - quantity = MIN; - } else if (quantity > MAX) { - quantity = MAX; - } + case "not-in-stock": + break; + default: // type: `simple` // todo: maybe it should be "variant" + if (quantity < MIN) { + quantity = MIN; + } else if (quantity > MAX) { + quantity = MAX; + } } return quantity; @@ -417,27 +417,20 @@ Meteor.methods({ userId: userId }); if (!cart) { - Logger.error(`Cart not found for user: ${ this.userId }`); - throw new Meteor.Error(404, "Cart not found", - "Cart not found for user with such id"); + Logger.error(`Cart not found for user: ${this.userId}`); + throw new Meteor.Error("cart-not-found", "Cart not found for user with such id"); } let cartItem; if (cart.items) { - cart.items.forEach(item => { - if (item._id === itemId) { - cartItem = item; - } - }); + cartItem = _.find(cart.items, (item) => item._id === itemId); } // extra check of item exists if (typeof cartItem !== "object") { - Logger.error(`Unable to find an item: ${itemId - } within the cart: ${cart._id}`); - throw new Meteor.Error(404, "Cart item not found.", - "Unable to find an item with such id within you cart."); + Logger.error(`Unable to find an item: ${itemId} within the cart: ${cart._id}`); + throw new Meteor.Error("cart-item-not-found", "Unable to find an item with such id in cart."); } // refresh shipping quotes @@ -447,7 +440,7 @@ Meteor.methods({ // reset selected shipment method Meteor.call("cart/resetShipmentMethod", cart._id); - if (!quantity) { + if (!quantity || quantity >= cartItem.quantity) { return Collections.Cart.update({ _id: cart._id }, { @@ -463,22 +456,19 @@ Meteor.methods({ "error removing from cart"); return error; } - if (result) { - Logger.info(`cart: deleted cart item variant id ${ - cartItem.variants._id}`); - return result; - } + Logger.info(`cart: deleted cart item variant id ${cartItem.variants._id}`); + return result; }); } // if quantity lets convert to negative and increment const removeQuantity = Math.abs(quantity) * -1; return Collections.Cart.update({ - _id: cart._id, - items: cartItem + "_id": cart._id, + "items._id": cartItem._id }, { $inc: { - "items.quantity": removeQuantity + "items.$.quantity": removeQuantity } }, (error, result) => { if (error) { @@ -487,11 +477,8 @@ Meteor.methods({ "error removing from cart"); return error; } - if (result) { - Logger.info(`cart: removed variant ${ - cartItem._id} quantity of ${quantity}`); - return result; - } + Logger.info(`cart: removed variant ${cartItem._id} quantity of ${quantity}`); + return result; }); }, @@ -578,6 +565,7 @@ Meteor.methods({ itemClone.quantity = 1; itemClone._id = Random.id(); + itemClone.cartItemId = item._id; // used for transitioning inventry itemClone.workflow = { status: "new" }; @@ -600,8 +588,9 @@ Meteor.methods({ order.items = expandedItems; if (!order.items || order.items.length === 0) { - throw new Meteor.Error( - "An error occurred saving the order. Missing cart items."); + const msg = "An error occurred saving the order. Missing cart items."; + Logger.error(msg); + throw new Meteor.Error("no-cart-items", msg); } // set new workflow status @@ -628,22 +617,23 @@ Meteor.methods({ // updating `cart/workflow/status` to "coreCheckoutShipping" // by calling `workflow/pushCartWorkflow` three times. This is the only // way to do that without refactoring of `workflow/pushCartWorkflow` - Meteor.call("workflow/pushCartWorkflow", "coreCartWorkflow", - "checkoutLogin"); - Meteor.call("workflow/pushCartWorkflow", "coreCartWorkflow", - "checkoutAddressBook"); - Meteor.call("workflow/pushCartWorkflow", "coreCartWorkflow", - "coreCheckoutShipping"); + Meteor.call("workflow/pushCartWorkflow", "coreCartWorkflow", "checkoutLogin"); + Meteor.call("workflow/pushCartWorkflow", "coreCartWorkflow", "checkoutAddressBook"); + Meteor.call("workflow/pushCartWorkflow", "coreCartWorkflow", "coreCheckoutShipping"); } Logger.info("Transitioned cart " + cartId + " to order " + orderId); // catch send notification, we don't want // to block because of notification errors - try { - Meteor.call("orders/sendNotification", Collections.Orders.findOne(orderId)); - } catch (error) { - Logger.warn(error, `Error in orders/sendNotification for ${orderId}`); + + if (order.email) { + Meteor.call("orders/sendNotification", Collections.Orders.findOne(orderId), (err) => { + if (err) { + Logger.error(err, `Error in orders/sendNotification for order ${orderId}`); + } + }); } + // order success return orderId; } diff --git a/server/methods/core/orders.js b/server/methods/core/orders.js index e4e82b666db..2345baf18d7 100644 --- a/server/methods/core/orders.js +++ b/server/methods/core/orders.js @@ -219,7 +219,8 @@ Meteor.methods({ check(shipment, Object); if (!Reaction.hasPermission("orders")) { - throw new Meteor.Error(403, "Access Denied"); + Logger.error("User does not have 'orders' permissions"); + throw new Meteor.Error("access-denied", "Access Denied"); } this.unblock(); @@ -227,9 +228,6 @@ Meteor.methods({ let completedItemsResult; let completedOrderResult; - // Attempt to sent email notification - const notifyResult = Meteor.call("orders/sendNotification", order); - const itemIds = shipment.items.map((item) => { return item._id; }); @@ -247,8 +245,17 @@ Meteor.methods({ } } + if (order.email) { + Meteor.call("orders/sendNotification", order, (err) => { + if (err) { + Logger.error(err, "orders/shipmentShipped: Failed to send notification"); + } + }); + } else { + Logger.warn("No order email found. No notification sent."); + } + return { - notifyResult: notifyResult, workflowResult: workflowResult, completedItems: completedItemsResult, completedOrder: completedOrderResult @@ -271,32 +278,37 @@ Meteor.methods({ this.unblock(); - if (order) { - let shipment = order.shipping[0]; - - // Attempt to sent email notification - Meteor.call("orders/sendNotification", order); + const shipment = order.shipping[0]; - const itemIds = shipment.items.map((item) => { - return item._id; + if (order.email) { + Meteor.call("orders/sendNotification", order, (err) => { + if (err) { + Logger.error(err, "orders/shipmentShipped: Failed to send notification"); + } }); + } else { + Logger.warn("No order email found. No notification sent."); + } - Meteor.call("workflow/pushItemWorkflow", "coreOrderItemWorkflow/delivered", order._id, itemIds); - Meteor.call("workflow/pushItemWorkflow", "coreOrderItemWorkflow/completed", order._id, itemIds); - - const isCompleted = _.every(order.items, (item) => { - return _.includes(item.workflow.workflow, "coreOrderItemWorkflow/completed"); - }); + const itemIds = shipment.items.map((item) => { + return item._id; + }); - if (isCompleted === true) { - Meteor.call("workflow/pushOrderWorkflow", "coreOrderWorkflow", "completed", order._id); - return true; - } + Meteor.call("workflow/pushItemWorkflow", "coreOrderItemWorkflow/delivered", order._id, itemIds); + Meteor.call("workflow/pushItemWorkflow", "coreOrderItemWorkflow/completed", order._id, itemIds); - Meteor.call("workflow/pushOrderWorkflow", "coreOrderWorkflow", "processing", order._id); + const isCompleted = _.every(order.items, (item) => { + return _.includes(item.workflow.workflow, "coreOrderItemWorkflow/completed"); + }); - return false; + if (isCompleted === true) { + Meteor.call("workflow/pushOrderWorkflow", "coreOrderWorkflow", "completed", order._id); + return true; } + + Meteor.call("workflow/pushOrderWorkflow", "coreOrderWorkflow", "processing", order._id); + + return false; }, /** @@ -309,50 +321,44 @@ Meteor.methods({ "orders/sendNotification": function (order) { check(order, Object); - // just make sure this a real userId - // todo: ddp limit - if (!Meteor.userId()) { - throw new Meteor.Error(403, "Access Denied"); + if (!this.userId) { + Logger.error("orders/sendNotification: Access denied"); + throw new Meteor.Error("access-denied", "Access Denied"); } this.unblock(); - if (order) { - let shop = Shops.findOne(order.shopId); - let shipment = order.shipping[0]; - - Reaction.configureMailUrl(); - Logger.info("orders/sendNotification", order.workflow.status); - // handle missing root shop email - if (!shop.emails[0].address) { - shop.emails[0].address = "no-reply@reactioncommerce.com"; - Logger.warn("No shop email configured. Using no-reply to send mail"); - } - // anonymous users without emails. - if (!order.email) { - Logger.warn("No shop email configured. Using anonymous order."); - return true; - } - // email templates can be customized in Templates collection - // loads defaults from reaction-email-templates/templates - let tpl = `orders/${order.workflow.status}`; - SSR.compileTemplate(tpl, ReactionEmailTemplate(tpl)); - try { - return Email.send({ - to: order.email, - from: `${shop.name} <${shop.emails[0].address}>`, - subject: `Order update from ${shop.name}`, - html: SSR.render(tpl, { - homepage: Meteor.absoluteUrl(), - shop: shop, - order: order, - shipment: shipment - }) - }); - } catch (error) { - Logger.fatal("Unable to send notification email: " + error); - throw new Meteor.Error("error-sending-email", "Unable to send order notification email.", error); - } + + const shop = Shops.findOne(order.shopId); + const shipment = order.shipping[0]; + + Logger.info(`orders/sendNotification status: ${order.workflow.status}`); + + // handle missing root shop email + if (!shop.emails[0].address) { + shop.emails[0].address = "no-reply@reactioncommerce.com"; + Logger.warn("No shop email configured. Using no-reply to send mail"); + } + + // anonymous users without emails. + if (!order.email) { + const msg = "No order email found. No notification sent."; + Logger.warn(msg); + throw new Meteor.Error("email-error", msg); } + + // email templates can be customized in Templates collection + // loads defaults from /private/email/templates + const tpl = `orders/${order.workflow.status}`; + SSR.compileTemplate(tpl, Reaction.Email.getTemplate(tpl)); + + Reaction.Email.send({ + to: order.email, + from: `${shop.name} <${shop.emails[0].address}>`, + subject: `Order update from ${shop.name}`, + html: SSR.render(tpl, { homepage: Meteor.absoluteUrl(), shop, order, shipment }) + }); + + return true; }, /** @@ -371,11 +377,9 @@ Meteor.methods({ this.unblock(); - if (order) { - Meteor.call("workflow/pushOrderWorkflow", - "coreOrderWorkflow", "coreOrderCompleted", order._id); - return this.orderCompleted(order); - } + Meteor.call("workflow/pushOrderWorkflow", "coreOrderWorkflow", "coreOrderCompleted", order._id); + + return this.orderCompleted(order); }, /** @@ -760,7 +764,7 @@ Meteor.methods({ }); if (result.saved === false) { - Logger.fatal("Attempt for refund transaction failed", order, paymentMethod.transactionId, result.error); + Logger.fatal("Attempt for refund transaction failed", order._id, paymentMethod.transactionId, result.error); throw new Meteor.Error( "Attempt to refund transaction failed", result.error); diff --git a/server/methods/core/registry.js b/server/methods/core/registry.js new file mode 100644 index 00000000000..c9971ee326c --- /dev/null +++ b/server/methods/core/registry.js @@ -0,0 +1,45 @@ +import { Meteor } from "meteor/meteor"; +import { check } from "meteor/check"; +import { Packages } from "/lib/collections"; +import { Reaction } from "/server/api"; + +Meteor.methods({ + "registry/update": function (packageId, name, fields) { + check(packageId, String); + check(name, String); + check(fields, Array); + let dataToSave = {}; + // settings use just the last name from full name + // so that schemas don't need to define overly complex + // names based with x/x/x formatting. + const setting = name.split("/").splice(-1); + dataToSave[setting] = {}; + + const currentPackage = Packages.findOne(packageId); + + _.each(fields, function (field) { + dataToSave[setting][field.property] = field.value; + }); + + if (currentPackage && currentPackage.settings) { + dataToSave = Object.assign({}, currentPackage.settings, dataToSave); + } + // user must have permission to package + // to update settings + if (Reaction.hasPermission([name])) { + return Packages.upsert({ + _id: packageId, + name: currentPackage.name, + enabled: currentPackage.enabled + }, { + $set: { + settings: dataToSave + } + }, { + upsert: true + }); + } + + return false; + } +}); diff --git a/server/methods/core/shipping.js b/server/methods/core/shipping.js index e149033ccb3..ab93bdf8be3 100644 --- a/server/methods/core/shipping.js +++ b/server/methods/core/shipping.js @@ -126,8 +126,7 @@ Meteor.methods({ } return _results; }); - Logger.info("getShippingrates returning rates"); - Logger.debug("rates", rates); + Logger.debug("getShippingrates returning rates", rates); return rates; } }); diff --git a/server/methods/core/shop.js b/server/methods/core/shop.js index 78636823372..765e49387cc 100644 --- a/server/methods/core/shop.js +++ b/server/methods/core/shop.js @@ -429,6 +429,7 @@ Meteor.methods({ }; let existingTag = Collections.Tags.findOne({ + slug: Reaction.getSlug(tagName), name: tagName }); diff --git a/server/publications/collections/cart.js b/server/publications/collections/cart.js index f33ec624561..76366512659 100644 --- a/server/publications/collections/cart.js +++ b/server/publications/collections/cart.js @@ -34,10 +34,18 @@ Meteor.publish("Cart", function (sessionId, userId) { return this.ready(); } + // exclude these fields + // from the client cart + const fields = { + taxes: 0 + }; + // select user cart const cart = Cart.find({ userId: this.userId, shopId: shopId + }, { + fields: fields }); if (cart.count()) { diff --git a/server/publications/collections/orders-publications.app-test.js b/server/publications/collections/orders-publications.app-test.js index 01e2de835a2..43f604f0e92 100644 --- a/server/publications/collections/orders-publications.app-test.js +++ b/server/publications/collections/orders-publications.app-test.js @@ -57,6 +57,9 @@ describe("Order Publication", function () { sandbox.stub(Meteor.server.method_handlers, "inventory/register", function () { check(arguments, [Match.Any]); }); + sandbox.stub(Meteor.server.method_handlers, "inventory/sold", function () { + check(arguments, [Match.Any]); + }); sandbox.stub(Reaction, "getShopId", () => shop._id); sandbox.stub(Roles, "userIsInRole", () => true); order = Factory.create("order", { status: "created" }); @@ -73,6 +76,9 @@ describe("Order Publication", function () { sandbox.stub(Meteor.server.method_handlers, "inventory/register", function () { check(arguments, [Match.Any]); }); + sandbox.stub(Meteor.server.method_handlers, "inventory/sold", function () { + check(arguments, [Match.Any]); + }); sandbox.stub(Reaction, "getShopId", () => shop._id); sandbox.stub(Roles, "userIsInRole", () => false); order = Factory.create("order", { status: "created" }); diff --git a/server/publications/collections/taxes.js b/server/publications/collections/taxes.js deleted file mode 100644 index 77e6d2ca8b8..00000000000 --- a/server/publications/collections/taxes.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Taxes } from "/lib/collections"; -import { Reaction } from "/server/api"; - -/** - * taxes - */ - -Meteor.publish("Taxes", function () { - const shopId = Reaction.getShopId(); - if (!shopId) { - return this.ready(); - } - return Taxes.find({ - shopId: shopId - }); -}); diff --git a/server/security/collections.js b/server/security/collections.js index 23f0f5499e7..20be79e349d 100644 --- a/server/security/collections.js +++ b/server/security/collections.js @@ -13,7 +13,6 @@ const { Shipping, Shops, Tags, - Taxes, Templates, Translations } = Collections; @@ -98,7 +97,6 @@ export default function () { Tags, Translations, Discounts, - Taxes, Shipping, Orders, Packages, diff --git a/server/security/index.js b/server/security/index.js index 516b3f59d17..d117823b6f1 100644 --- a/server/security/index.js +++ b/server/security/index.js @@ -1,5 +1,7 @@ import Collections from "./collections"; +import RateLimiters from "./rate-limits"; export default function () { Collections(); + RateLimiters(); } diff --git a/server/security/rate-limits.js b/server/security/rate-limits.js new file mode 100644 index 00000000000..da9d01dcbaf --- /dev/null +++ b/server/security/rate-limits.js @@ -0,0 +1,41 @@ +import _ from "lodash"; +import { DDPRateLimiter } from "meteor/ddp-rate-limiter"; + + +export default function () { + /** + * Rate limit Meteor Accounts methods + * 2 attempts per connection per 5 seconds + */ + const authMethods = [ + "login", + "logout", + "logoutOtherClients", + "getNewToken", + "removeOtherTokens", + "configureLoginService", + "changePassword", + "forgotPassword", + "resetPassword", + "verifyEmail", + "createUser", + "ATRemoveService", + "ATCreateUserServer", + "ATResendVerificationEmail" + ]; + + DDPRateLimiter.addRule({ + name: (name) => _.includes(authMethods, name), + connectionId: () => true + }, 2, 5000); + + + /** + * Rate limit "orders/sendNotification" + * 1 attempt per connection per 2 seconds + */ + DDPRateLimiter.addRule({ + name: "orders/sendNotification", + connectionId: () => true + }, 1, 2000); +}