diff --git a/.meteor/packages b/.meteor/packages index 630632dac08..94f81764a9a 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -69,6 +69,7 @@ kadira:flow-router-ssr matb33:collection-hooks meteorhacks:ssr meteorhacks:subs-manager +natestrauser:select2 ongoworks:security raix:ui-dropped-event risul:moment-timezone @@ -94,5 +95,4 @@ johanbrook:publication-collector # spiderable # adds phantomjs SEO rendering, use ongoworks:spiderable with Docker # meteorhacks:sikka # additional ddp, login security - # Custom Packages diff --git a/.meteor/versions b/.meteor/versions index 9ff8424fe9a..4fc5031f575 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -122,6 +122,7 @@ mongo@1.1.15 mongo-id@1.0.6 mongo-livedata@1.0.12 mrt:later@1.6.1 +natestrauser:select2@4.0.3 npm-bcrypt@0.9.2 npm-mongo@2.2.16_1 oauth@1.1.13 diff --git a/client/collections/index.js b/client/collections/index.js index fed7f1632cd..811bf1db0b0 100644 --- a/client/collections/index.js +++ b/client/collections/index.js @@ -1 +1,2 @@ export * from "./countries"; +export * from "./taxEntitycodes"; diff --git a/client/collections/taxEntitycodes.js b/client/collections/taxEntitycodes.js new file mode 100644 index 00000000000..d73541dfc11 --- /dev/null +++ b/client/collections/taxEntitycodes.js @@ -0,0 +1,6 @@ +import { Mongo } from "meteor/mongo"; + +/** + * Client side collections + */ +export const TaxEntityCodes = new Mongo.Collection(null); diff --git a/client/modules/accounts/templates/dashboard/dashboard.html b/client/modules/accounts/templates/dashboard/dashboard.html index d055c0fa5d3..e2b308de3b2 100644 --- a/client/modules/accounts/templates/dashboard/dashboard.html +++ b/client/modules/accounts/templates/dashboard/dashboard.html @@ -39,7 +39,6 @@

{{/each}} - {{/if}} diff --git a/client/modules/accounts/templates/dashboard/dashboard.js b/client/modules/accounts/templates/dashboard/dashboard.js index 36d236233d1..3771052753b 100644 --- a/client/modules/accounts/templates/dashboard/dashboard.js +++ b/client/modules/accounts/templates/dashboard/dashboard.js @@ -1,8 +1,8 @@ import _ from "lodash"; import { Meteor } from "meteor/meteor"; +import { Template } from "meteor/templating"; import { Reaction, i18next } from "/client/api"; import { ServiceConfigHelper } from "../../helpers/util"; -import { Template } from "meteor/templating"; /** * Accounts helpers @@ -16,6 +16,7 @@ Template.accountsDashboard.onCreated(function () { Template.accountsDashboard.helpers({ /** * isShopMember + * @param {Object} member member object * @return {Boolean} True if the memnber is an administrator */ isShopMember() { @@ -24,12 +25,12 @@ Template.accountsDashboard.helpers({ /** * isShopGuest + * @param {Object} member member object * @return {Boolean} True if the member is a guest */ isShopGuest() { return !_.includes(["dashboard", "admin", "owner"], this.role); }, - /** * members * @return {Boolean} True array of adminsitrative members diff --git a/client/modules/accounts/templates/members/member.html b/client/modules/accounts/templates/members/member.html index 007d9e31035..52e3186cb8e 100644 --- a/client/modules/accounts/templates/members/member.html +++ b/client/modules/accounts/templates/members/member.html @@ -7,6 +7,10 @@ {{displayName this}}  ({{this.email}}) +
+ Customer Id: {{userId}} +
+
@@ -27,7 +31,9 @@ - + {{#if showAvalaraTaxSettings }} + {{> taxSettingsPanel member=this }} + {{/if}} {{#each groupsForUser}} {{#if permissionGroups}} diff --git a/client/modules/accounts/templates/members/member.js b/client/modules/accounts/templates/members/member.js index d590413bde3..801e7f5b04f 100644 --- a/client/modules/accounts/templates/members/member.js +++ b/client/modules/accounts/templates/members/member.js @@ -1,3 +1,4 @@ +import _ from "lodash"; import { Reaction } from "/client/api"; import { Packages, Shops } from "/lib/collections"; import { Meteor } from "meteor/meteor"; @@ -36,6 +37,9 @@ Template.memberSettings.helpers({ } } }, + userId: function () { + return Meteor.userId(); + }, hasPermissionChecked: function (permission, userId) { if (userId && Roles.userIsInRole(userId, permission, this.shopId || Roles.userIsInRole(userId, permission, Roles.GLOBAL_GROUP))) { @@ -110,6 +114,18 @@ Template.memberSettings.helpers({ hasManyPermissions: function (permissions) { return Boolean(permissions.length); + }, + /** + * showAvalaraTaxSettings + * @return {Boolean} True if avalara is enabled. Defaults to false if not found + */ + showAvalaraTaxSettings() { + const avalara = Packages.findOne({ + name: "taxes-avalara", + shopId: Reaction.getShopId() + }); + + return _.get(avalara, "settings.avalara.enabled", false); } }); diff --git a/imports/plugins/core/email/client/actions/logs.js b/imports/plugins/core/email/client/actions/logs.js index eb50edc1018..f387e3eee90 100644 --- a/imports/plugins/core/email/client/actions/logs.js +++ b/imports/plugins/core/email/client/actions/logs.js @@ -1,4 +1,4 @@ -import { Router, i18next } from "/client/api"; +import { i18next } from "/client/api"; export default { /** diff --git a/imports/plugins/core/email/client/components/emailSettings.js b/imports/plugins/core/email/client/components/emailSettings.js index e3c4ceefee7..6b022b2a461 100644 --- a/imports/plugins/core/email/client/components/emailSettings.js +++ b/imports/plugins/core/email/client/components/emailSettings.js @@ -31,7 +31,7 @@ class EmailSettings extends Component { handleSelect(e) { const { settings } = this.state; - settings["service"] = e; + settings.service = e; this.setState({ settings }); } diff --git a/imports/plugins/core/logging/register.js b/imports/plugins/core/logging/register.js new file mode 100644 index 00000000000..5db8069f208 --- /dev/null +++ b/imports/plugins/core/logging/register.js @@ -0,0 +1,7 @@ +import { Reaction } from "/server/api"; + +Reaction.registerPackage({ + label: "Logging", + name: "reaction-logging", + autoEnable: true +}); diff --git a/imports/plugins/core/logging/server/index.js b/imports/plugins/core/logging/server/index.js new file mode 100644 index 00000000000..a608e2019a2 --- /dev/null +++ b/imports/plugins/core/logging/server/index.js @@ -0,0 +1 @@ +import "./publications"; diff --git a/imports/plugins/core/logging/server/publications.js b/imports/plugins/core/logging/server/publications.js new file mode 100644 index 00000000000..22097c1674c --- /dev/null +++ b/imports/plugins/core/logging/server/publications.js @@ -0,0 +1,28 @@ +import { Meteor } from "meteor/meteor"; +import { check, Match } from "meteor/check"; +import { Roles } from "meteor/alanning:roles"; +import { Counts } from "meteor/tmeasday:publish-counts"; +import { Logs } from "/lib/collections"; +import { Reaction } from "/server/api"; + + +/** + * Publish logs + * Poor admins get swamped with a ton of data so let's just only subscribe to one + * logType at a time + */ +Meteor.publish("Logs", function (query, options) { + check(query, Match.OneOf(undefined, Object)); + check(options, Match.OneOf(undefined, Object)); + + const shopId = Reaction.getShopId(); + if (!query || !query.logType || !shopId) { + return this.ready(); + } + + const logType = query.logType; + if (Roles.userIsInRole(this.userId, ["admin", "owner"])) { + Counts.publish(this, "logs-count", Logs.find({ shopId, logType })); + return Logs.find({ shopId, logType }, { sort: { date: 1 } }); + } +}); diff --git a/imports/plugins/core/revisions/client/containers/publishContainer.js b/imports/plugins/core/revisions/client/containers/publishContainer.js index 915c32f9dc6..9a5c688dee0 100644 --- a/imports/plugins/core/revisions/client/containers/publishContainer.js +++ b/imports/plugins/core/revisions/client/containers/publishContainer.js @@ -31,7 +31,7 @@ class PublishContainer extends Component { Alerts.toast(message, "success"); if (this.props.onPublishSuccess) { - this.props.onPublishSuccess(result) + this.props.onPublishSuccess(result); } } else { const message = i18next.t("revisions.noChangesPublished", { diff --git a/imports/plugins/core/taxes/lib/collections/schemas/taxcodes.js b/imports/plugins/core/taxes/lib/collections/schemas/taxcodes.js index fdacd208696..67af433b009 100644 --- a/imports/plugins/core/taxes/lib/collections/schemas/taxcodes.js +++ b/imports/plugins/core/taxes/lib/collections/schemas/taxcodes.js @@ -11,12 +11,21 @@ export const TaxCodes = new SimpleSchema({ unique: true }, shopId: { + type: String + }, + taxCode: { type: String, - optional: true + label: "Tax Code" + }, + taxCodeProvider: { + type: String, + label: "Tax Code Provider" }, ssuta: { type: Boolean, - label: "Streamlined Sales Tax" + label: "Streamlined Sales Tax", + optional: true, + defaultValue: false }, title: { type: String, diff --git a/imports/plugins/included/default-theme/client/styles/products/variantForm.less b/imports/plugins/included/default-theme/client/styles/products/variantForm.less index 5642958c68b..fcc3fc0650a 100644 --- a/imports/plugins/included/default-theme/client/styles/products/variantForm.less +++ b/imports/plugins/included/default-theme/client/styles/products/variantForm.less @@ -22,10 +22,10 @@ } .checkbox,.radio { - margin-top: 0px; - margin-bottom: 0px; - } - .lowInventoryWarningThreshold { - + margin-top: 0; + margin-bottom: 0; } } +.select2-container { + width: 370px; +} diff --git a/imports/plugins/included/product-admin/client/components/productAdmin.js b/imports/plugins/included/product-admin/client/components/productAdmin.js index 0b5632a4b50..a4a01f41040 100644 --- a/imports/plugins/included/product-admin/client/components/productAdmin.js +++ b/imports/plugins/included/product-admin/client/components/productAdmin.js @@ -22,6 +22,7 @@ const fieldNames = [ "subtitle", "vendor", "description", + "origincountry", "facebookMsg", "twitterMsg", "pinterestMsg", @@ -306,6 +307,17 @@ class ProductAdmin extends Component { ref="descriptionInput" value={this.product.description} /> + +
+ + {{>afFieldInput name='originCountry' placeholder=(i18n "productVariant.originCountry" "Origin Country")}} + {{#if afFieldIsInvalid name='originCountry'}} + {{afFieldMessage name='originCountry'}} + {{/if}} +
+
{{>afFieldInput name='weight' placeholder="0"}} @@ -104,16 +112,65 @@ {{/if}}
+
+ + {{>afFieldInput name='length' placeholder="0"}} + {{#if afFieldIsInvalid name='length'}} + {{afFieldMessage name='length'}} + {{/if}} +
+ +
+ + {{>afFieldInput name='width' placeholder="0"}} + {{#if afFieldIsInvalid name='width'}} + {{afFieldMessage name='width'}} + {{/if}} +
+ +
+ + {{>afFieldInput name='height' placeholder="0"}} + {{#if afFieldIsInvalid name='height'}} + {{afFieldMessage name='height'}} + {{/if}} +
-
+
{{>afFieldInput name='taxable'}} {{#if afFieldIsInvalid name='taxable'}} {{afFieldMessage name='taxable'}} {{/if}}
+ {{#unless isProviderEnabled}} +
+ + {{>afFieldInput name='taxCode'}} +
+ {{else}} +
+ + {{#if afFieldIsInvalid name='taxCode'}} + {{afFieldMessage name='taxCode'}} + {{/if}} +
+ {{/unless}} + +
+ + {{>afFieldInput name='taxDescription' placeholder=(i18n "productVariant.taxDescription" "Tax Description")}} + {{#if afFieldIsInvalid name='taxDescription'}} + {{afFieldMessage name='taxDescription'}} + {{/if}} +
{{>afFieldInput name='inventoryManagement'}} {{#if afFieldIsInvalid name='inventoryManagement'}} @@ -126,7 +183,7 @@ {{afFieldMessage name='inventoryPolicy'}} {{/if}}
-
+
{{>afFieldInput name='lowInventoryWarningThreshold' placeholder="0"}} {{#if afFieldIsInvalid name='lowInventoryWarningThreshold'}} 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 79d8df7e29e..c99598f01c2 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 @@ -1,13 +1,21 @@ import { Meteor } from "meteor/meteor"; +import { ReactiveDict } from "meteor/reactive-dict"; import { Session } from "meteor/session"; import { Template } from "meteor/templating"; import { Reaction, i18next } from "/client/api"; import { ReactionProduct } from "/lib/api"; import { applyProductRevision } from "/lib/api/products"; -import { Products } from "/lib/collections"; +import { Packages, Products } from "/lib/collections"; +import { TaxCodes } from "/imports/plugins/core/taxes/lib/collections"; Template.variantForm.onCreated(function () { + this.state = new ReactiveDict(); + this.state.set("taxCodes", []); + this.autorun(() => { + // subscribe to TaxCodes + Meteor.subscribe("TaxCodes"); + const productHandle = Reaction.Router.getParam("handle"); if (!productHandle) { @@ -21,6 +29,12 @@ Template.variantForm.onCreated(function () { }; }); +Template.variantForm.onRendered(function () { + $("#taxCode").select2({ + placeholder: "Select Tax Code" + }); +}); + /** * variantForm helpers */ @@ -68,6 +82,11 @@ Template.variantForm.helpers({ return "display:none;"; } }, + displayTaxCodes: function () { + if (this.taxable !== true) { + return "display:none;"; + } + }, removeVariant(variant) { return () => { return () => { @@ -114,6 +133,78 @@ Template.variantForm.helpers({ }); }; }; + }, + isProviderEnabled: function () { + const shopId = Reaction.getShopId(); + + const provider = Packages.findOne({ + "shopId": shopId, + "registry.provides": "taxCodes", + "$where": function () { + const providerName = this.name.split("-")[1]; + return this.settings[providerName].enabled; + } + }); + + if (provider) { + return true; + } + }, + listTaxCodes: function () { + const instance = Template.instance(); + const shopId = Reaction.getShopId(); + + const provider = Packages.findOne({ + "shopId": shopId, + "registry.provides": "taxCodes", + "$where": function () { + const providerName = this.name.split("-")[1]; + return this.settings[providerName].enabled; + } + }); + + if (provider) { + if (Meteor.subscribe("TaxCodes").ready() && TaxCodes.find({}).count() === 0) { + Meteor.call(provider.settings.taxCodes.getTaxCodeMethod, (error, result) => { + if (error) { + throw new Meteor.Error(`Error calling method ${provider.settings.taxCodes.getTaxCodeMethod}`, error); + } else if (result && Array.isArray(result)) { + result.forEach(function (code) { + Meteor.call("taxes/insertTaxCodes", shopId, code, provider.name, (err) => { + if (err) { + throw new Meteor.Error("Error populating TaxCodes collection", err); + } + return; + }); + }); + } + }); + Meteor.call("taxes/fetchTaxCodes", shopId, provider.name, (err, res) => { + if (err) { + throw new Meteor.Error("Error fetching records", err); + } else { + instance.state.set("taxCodes", res); + } + }); + } else { + Meteor.call("taxes/fetchTaxCodes", shopId, provider.name, (err, res) => { + if (err) { + throw new Meteor.Error("Error fetching records", err); + } else { + instance.state.set("taxCodes", res); + } + }); + } + } else { + return false; + } + return instance.state.get("taxCodes"); + }, + displayCode: function () { + if (this.taxCode && this.taxCode !== "00000") { + return this.taxCode; + } + return i18next.t("productVariant.selectTaxCode"); } }); @@ -140,6 +231,25 @@ Template.variantForm.events({ }); } } + } else if (field === "taxCode" || field === "taxDescription") { + const value = Template.instance().$(event.currentTarget).prop("value"); + Meteor.call("products/updateProductField", template.data._id, field, value, + error => { + if (error) { + throw new Meteor.Error("error updating variant", error); + } + }); + if (ReactionProduct.checkChildVariants(template.data._id) > 0) { + const childVariants = ReactionProduct.getVariants(template.data._id); + for (const 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); diff --git a/imports/plugins/included/product-variant/server/index.js b/imports/plugins/included/product-variant/server/index.js index 3979f964b5a..2f312565ff9 100644 --- a/imports/plugins/included/product-variant/server/index.js +++ b/imports/plugins/included/product-variant/server/index.js @@ -1 +1,2 @@ import "./i18n"; +import "./methods"; diff --git a/imports/plugins/included/product-variant/server/methods/index.js b/imports/plugins/included/product-variant/server/methods/index.js new file mode 100644 index 00000000000..919619be0b4 --- /dev/null +++ b/imports/plugins/included/product-variant/server/methods/index.js @@ -0,0 +1 @@ +import "./populateTaxCodes.js"; diff --git a/imports/plugins/included/product-variant/server/methods/populateTaxCodes.js b/imports/plugins/included/product-variant/server/methods/populateTaxCodes.js new file mode 100644 index 00000000000..2ffe36ecd07 --- /dev/null +++ b/imports/plugins/included/product-variant/server/methods/populateTaxCodes.js @@ -0,0 +1,64 @@ +import { Meteor } from "meteor/meteor"; +import { TaxCodes } from "/imports/plugins/core/taxes/lib/collections"; + +const taxCodes = {}; + +/* + * taxes/insertTaxCodes + * @summary populate TaxCodes collection + * @param {String} shopID - current shop's id + * @param {Object} code - tax code object to insert into TaxCodes collection + * @param {String} providerName - tax code provider + * @return {} undefined + */ +taxCodes.populateTaxCodes = function (shopId, code, providerName) { + check(shopId, String); + check(code, Object); + check(providerName, String); + + try { + TaxCodes.insert({ + id: code.id, + shopId: shopId, + taxCode: code.taxCode, + taxCodeProvider: providerName, + ssuta: code.isSSTCertified, + label: code.description, + parent: code.parentTaxCode + }); + } catch (err) { + throw new Meteor.Error("Error populating TaxCodes collection", err); + } +}; + +/* + * taxes/getTaxCodes + * @summary fetch tax codes from TaxCodes collection + * @param {String} shopID - current shop's id + * @param {String} provider - tax code provider + * @return {Array} array of tax codes + */ +taxCodes.fetchTaxCodes = function (shopId, provider) { + check(shopId, String); + check(provider, String); + + const taxCodesArray = []; + + const codes = TaxCodes.find({ + shopId: shopId, + taxCodeProvider: provider + }); + + codes.forEach(function (code) { + taxCodesArray.push({ + value: code.taxCode, + label: `${code.taxCode} | ${code.label}` + }); + }); + return taxCodesArray; +}; + +Meteor.methods({ + "taxes/insertTaxCodes": taxCodes.populateTaxCodes, + "taxes/fetchTaxCodes": taxCodes.fetchTaxCodes +}); diff --git a/imports/plugins/included/sms/client/components/smsSettings.js b/imports/plugins/included/sms/client/components/smsSettings.js index 2a708fc94a1..880254d5556 100644 --- a/imports/plugins/included/sms/client/components/smsSettings.js +++ b/imports/plugins/included/sms/client/components/smsSettings.js @@ -30,7 +30,7 @@ class SmsSettings extends Component { handleSelect(e) { const { settings } = this.state; - settings["smsProvider"] = e; + settings.smsProvider = e; this.setState({ settings }); } diff --git a/imports/plugins/included/taxes-avalara/client/accounts/exemption.html b/imports/plugins/included/taxes-avalara/client/accounts/exemption.html new file mode 100644 index 00000000000..66d1e9d307a --- /dev/null +++ b/imports/plugins/included/taxes-avalara/client/accounts/exemption.html @@ -0,0 +1,21 @@ + diff --git a/imports/plugins/included/taxes-avalara/client/accounts/exemption.js b/imports/plugins/included/taxes-avalara/client/accounts/exemption.js new file mode 100644 index 00000000000..35f2027f5f0 --- /dev/null +++ b/imports/plugins/included/taxes-avalara/client/accounts/exemption.js @@ -0,0 +1,89 @@ +import _ from "lodash"; +import { Meteor } from "meteor/meteor"; +import { Template } from "meteor/templating"; +import { Reaction, i18next } from "/client/api"; +import { Packages, Accounts } from "/lib/collections"; +import { Accounts as AccountsSchema } from "/lib/collections/schemas/accounts"; +import { TaxEntityCodes } from "/client/collections"; + +Template.taxSettingsPanel.helpers({ + account() { + const sub = Meteor.subscribe("UserAccount", this.member.userId); + if (sub.ready()) { + return Accounts.findOne({ _id: this.member.userId }); + } + return null; + }, + makeUniqueId() { + return `tax-settings-form-${this.member.userId}`; + }, + accountsSchema() { + return AccountsSchema; + }, + entityCodes() { + const customOption = [{ + label: i18next.t("admin.taxSettings.entityCodeCustomLabel"), + value: "CUSTOM USER INPUT" + }]; + + const entityCodes = TaxEntityCodes.find().map((entityCode) => { + return Object.assign({}, entityCode, { + label: entityCode.name, + value: entityCode.code + }); + }); + + return (entityCodes || []).concat(customOption); + } +}); + +Template.taxSettingsPanel.events({ + "change [data-event-action=customType]": function (event) { + event.stopPropagation(); + + if (isCustomValue()) { + return $(".customerUsageType").toggleClass("hide"); + } + $(".customerUsageType").addClass("hide"); + } +}); + +Template.taxSettingsPanel.onCreated(function () { + const avalaraPackage = Packages.findOne({ + name: "taxes-avalara", + shopId: Reaction.getShopId() + }); + const isAvalaraEnabled = _.get(avalaraPackage, "settings.avalara.enabled", false); + const currentCodes = TaxEntityCodes.find().fetch(); + + if (isAvalaraEnabled && !currentCodes.length) { + Meteor.call("avalara/getEntityCodes", (error, entityCodes) => { + if (error) { + return Alerts.toast( + `${i18next.t("settings.apiError")} ${error.message}`, "error" + ); + } + (entityCodes || []).forEach((entityCode) => TaxEntityCodes.insert(entityCode)); + }); + } +}); + +AutoForm.hooks({ + "tax-settings-form": { + before: { + update: function (doc) { + if (isCustomValue()) { + const value = $(".customerUsageType input").val(); + doc.$set["taxSettings.customerUsageType"] = value; + } + return doc; + } + } + } +}); + +function isCustomValue() { + const formData = AutoForm.getFormValues("tax-settings-form"); + const value = _.get(formData, "insertDoc.taxSettings.customerUsageType"); + return value === "CUSTOM USER INPUT"; +} diff --git a/imports/plugins/included/taxes-avalara/client/index.js b/imports/plugins/included/taxes-avalara/client/index.js index e0102291566..c8e0dd63e3d 100644 --- a/imports/plugins/included/taxes-avalara/client/index.js +++ b/imports/plugins/included/taxes-avalara/client/index.js @@ -1,2 +1,5 @@ import "./settings/avalara.html"; +import "./accounts/exemption.html"; import "./settings/avalara.js"; +import "./accounts/exemption.js"; +import "./styles/settings.less"; diff --git a/imports/plugins/included/taxes-avalara/client/settings/avagriddle.js b/imports/plugins/included/taxes-avalara/client/settings/avagriddle.js new file mode 100644 index 00000000000..82fc37e00d1 --- /dev/null +++ b/imports/plugins/included/taxes-avalara/client/settings/avagriddle.js @@ -0,0 +1,78 @@ +/* eslint react/prop-types:0, react/jsx-sort-props:0, react/forbid-prop-types: 0, "react/prefer-es6-class": [1, "never"] */ +import _ from "lodash"; +import React from "react"; +import moment from "moment"; +import Griddle from "griddle-react"; +import { Counts } from "meteor/tmeasday:publish-counts"; +import { ReactMeteorData } from "meteor/react-meteor-data"; + +const LogGriddle = React.createClass({ + propTypes: { + collection: React.PropTypes.object, + matchingResultsCount: React.PropTypes.string, + publication: React.PropTypes.string, + subscriptionParams: React.PropTypes.object + }, + mixins: [ReactMeteorData], + + getInitialState() { + return { + currentPage: 0, + maxPages: 0, + externalResultsPerPage: this.props.externalResultsPerPage, + externalSortColumn: this.props.externalSortColumn, + externalSortAscending: this.props.externalSortAscending + }; + }, + + getMeteorData() { + const matchingResults = Counts.get(this.props.matchingResultsCount); + const pubHandle = Meteor.subscribe(this.props.publication, this.props.subscriptionParams); + const rawResults = this.props.collection.find({}).fetch(); + let results; + if (rawResults) { + results = rawResults.map((o) => { + return { + date: moment(o.data).format("MM/DD/YYYY HH:mm:ss"), + docType: _.get(o, "data.request.data.type", ""), + request: JSON.stringify(o.data.request), + result: JSON.stringify(o.data.result), + _id: o._id + }; + }); + } + + return { + loading: !pubHandle.ready(), + results, + matchingResults + }; + }, + + setPage(index) { + this.setState({ currentPage: index }); + }, + + render() { + const maxPages = Math.ceil(this.data.matchingResults / this.state.externalResultsPerPage); + const allProps = this.props; + + return ( + + ); + } + +}); + +export default LogGriddle; diff --git a/imports/plugins/included/taxes-avalara/client/settings/avalara.html b/imports/plugins/included/taxes-avalara/client/settings/avalara.html index cad97222e36..b670e09a6d6 100644 --- a/imports/plugins/included/taxes-avalara/client/settings/avalara.html +++ b/imports/plugins/included/taxes-avalara/client/settings/avalara.html @@ -1,28 +1,95 @@ diff --git a/imports/plugins/included/taxes-avalara/client/settings/avalara.js b/imports/plugins/included/taxes-avalara/client/settings/avalara.js index aa7402b50d4..1325e5b072c 100644 --- a/imports/plugins/included/taxes-avalara/client/settings/avalara.js +++ b/imports/plugins/included/taxes-avalara/client/settings/avalara.js @@ -1,22 +1,164 @@ +import _ from "lodash"; import { Template } from "meteor/templating"; +import { ReactiveDict } from "meteor/reactive-dict"; +import { Meteor } from "meteor/meteor"; import { AutoForm } from "meteor/aldeed:autoform"; +import { Countries } from "/client/collections"; import { Reaction, i18next } from "/client/api"; -import { Packages } from "/lib/collections"; +import { Packages, Logs } from "/lib/collections"; +import { Logs as LogSchema } from "/lib/collections/schemas/logs"; import { AvalaraPackageConfig } from "../../lib/collections/schemas"; +import LogGriddle from "./avagriddle"; +import { Loading } from "/imports/plugins/core/ui/client/components"; +function getPackageData() { + return Packages.findOne({ + name: "taxes-avalara", + shopId: Reaction.getShopId() + }); +} + + +Template.avalaraSettings.onCreated(function () { + this.autorun(() => { + this.subscribe("Logs", { + logType: "avalara" + }); + }); + + this.state = new ReactiveDict(); + this.state.setDefault({ + isEditing: false, + editingId: null + }); +}); + +// Avalara supports only Canada and US for address validation +const countryDefaults = ["US", "CA"]; + Template.avalaraSettings.helpers({ packageConfigSchema() { return AvalaraPackageConfig; }, packageData() { - return Packages.findOne({ - name: "taxes-avalara", - shopId: Reaction.getShopId() + return getPackageData(); + }, + logSchema() { + return LogSchema; + }, + logCollection() { + return Logs; + }, + countryOptions() { + return Countries.find({ value: { $in: countryDefaults } }).fetch(); + }, + countryDefaults() { + return countryDefaults; + }, + currentCountryList() { + return AutoForm.getFieldValue("settings.addressValidation.countryList"); + }, + loggingEnabled() { + const pkgData = getPackageData(); + return pkgData.settings.avalara.enableLogging; + }, + + logGrid() { + const fields = ["date", "docType"]; + const noDataMessage = i18next.t("logGrid.noLogsFound"); + const instance = Template.instance(); + + function editRow(options) { + const currentId = instance.state.get("editingId"); + 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 "log-grid-row"; + } + }; + + // add i18n handling to headers + const customColumnMetadata = []; + fields.forEach(function (field) { + const columnMeta = { + columnName: field, + displayName: i18next.t(`logGrid.columns.${field}`) + }; + customColumnMetadata.push(columnMeta); }); + + // return template Grid + return { + component: LogGriddle, + publication: "Logs", + collection: Logs, + matchingResultsCount: "logs-count", + useGriddleStyles: false, + rowMetadata: customRowMetaData, + columns: fields, + noDataMessage: noDataMessage, + onRowClick: editRow, + columnMetadata: customColumnMetadata, + externalLoadingComponent: Loading, + subscriptionParams: { logType: "avalara" } + }; + }, + + instance() { + const instance = Template.instance(); + return instance; + }, + + logEntry() { + const instance = Template.instance(); + const id = instance.state.get("editingId"); + const log = Logs.findOne(id) || {}; + log.data = JSON.stringify(log.data, null, 4); + return log; } + + }); +Template.avalaraSettings.events({ + "click .template-grid-row": function (event) { + // toggle all rows off, then add our active row + $(".template-grid-row").removeClass("active"); + Template.instance().$(event.currentTarget).addClass("active"); + }, + "click [data-event-action=testCredentials]": function (event) { + const formId = "avalara-update-form"; + if (!AutoForm.validateForm(formId)) { + return null; + } + event.preventDefault(); + event.stopPropagation(); + const formData = AutoForm.getFormValues(formId); + const settings = _.get(formData, "insertDoc.settings.avalara"); + + Meteor.call("avalara/testCredentials", settings, function (error, result) { + if (error && error.message) { + return Alerts.toast(`${i18next.t("settings.testCredentialsFailed")} ${error.message}`, "error"); + } + const statusCode = _.get(result, "statusCode"); + const connectionValid = _.inRange(statusCode, 400); + if (connectionValid) { + return Alerts.toast(i18next.t("settings.testCredentialsSuccess"), "success"); + } + return Alerts.toast(i18next.t("settings.testCredentialsFailed"), "error"); + }); + } +}); AutoForm.hooks({ "avalara-update-form": { diff --git a/imports/plugins/included/taxes-avalara/client/styles/settings.less b/imports/plugins/included/taxes-avalara/client/styles/settings.less new file mode 100644 index 00000000000..19e342e048a --- /dev/null +++ b/imports/plugins/included/taxes-avalara/client/styles/settings.less @@ -0,0 +1,6 @@ +.avalara-login-box { + border: 1px solid #b7d7d0; + margin: 30px 0; + width: 100%; + font-size: 16px; +} diff --git a/imports/plugins/included/taxes-avalara/lib/collections/schemas/schema.js b/imports/plugins/included/taxes-avalara/lib/collections/schemas/schema.js index e59e3628fdf..25470182de8 100644 --- a/imports/plugins/included/taxes-avalara/lib/collections/schemas/schema.js +++ b/imports/plugins/included/taxes-avalara/lib/collections/schemas/schema.js @@ -23,6 +23,12 @@ export const AvalaraPackageConfig = new SimpleSchema([ "settings.avalara.username": { type: String }, + "settings.avalara.companyCode": { + type: String + }, + "settings.avalara.companyId": { + type: String + }, "settings.avalara.password": { type: String }, @@ -31,10 +37,44 @@ export const AvalaraPackageConfig = new SimpleSchema([ type: Boolean, defaultValue: false }, + "settings.avalara.shippingTaxCode": { + label: "Shipping Tax Code", + type: String + }, "settings.addressValidation.enabled": { label: "Address Validation", type: Boolean, + defaultValue: true + }, + "settings.avalara.commitDocuments": { + label: "Commit Documents", + type: Boolean, + defaultValue: true + }, + "settings.avalara.performTaxCalculation": { + label: "Perform Tax Calculation", + type: Boolean, + defaultValue: true + }, + "settings.avalara.enableLogging": { + label: "Enable Transaction Logging", + type: Boolean, defaultValue: false + }, + "settings.avalara.logRetentionDuration": { + label: "Retain Logs Duration (Days)", + type: Number, + defaultValue: 30 + }, + "settings.avalara.requestTimeout": { + label: "Request Timeout", + type: Number, + defaultValue: 1500 + }, + "settings.addressValidation.countryList": { + label: "Enable Address Validation by Country", + type: [String], + optional: true } } ]); diff --git a/imports/plugins/included/taxes-avalara/register.js b/imports/plugins/included/taxes-avalara/register.js index fb1d851aaf5..98125b9d51b 100644 --- a/imports/plugins/included/taxes-avalara/register.js +++ b/imports/plugins/included/taxes-avalara/register.js @@ -8,11 +8,22 @@ Reaction.registerPackage({ settings: { avalara: { enabled: false, - apiLoginId: "" + apiLoginId: "", + username: "", + password: "", + mode: false, + commitDocuments: true, + performTaxCalculation: true, + enableLogging: false, + requestTimeout: 3000, + logRetentionDuration: 30 }, addressValidation: { - enabled: false, + enabled: true, addressValidationMethod: "avalara/addressValidation" + }, + taxCodes: { + getTaxCodeMethod: "avalara/getTaxCodes" } }, registry: [ @@ -24,8 +35,18 @@ Reaction.registerPackage({ }, { label: "Avalara Address Validation", - name: "addressValidation/avalara", + name: "taxes/addressValidation/avalara", provides: "addressValidation" + }, + { + label: "Avalara Tax Calculation", + provides: "taxMethod", + name: "taxes/calculation/avalara" + }, + { + label: "Avalara Tax Codes", + provides: "taxCodes", + name: "taxes/taxcodes/avalara" } ] }); diff --git a/imports/plugins/included/taxes-avalara/server/hooks/hooks.js b/imports/plugins/included/taxes-avalara/server/hooks/hooks.js index d34a8306594..c615ca971c9 100644 --- a/imports/plugins/included/taxes-avalara/server/hooks/hooks.js +++ b/imports/plugins/included/taxes-avalara/server/hooks/hooks.js @@ -3,27 +3,45 @@ import { Logger, MethodHooks } from "/server/api"; import { Cart, Orders } from "/lib/collections"; import taxCalc from "../methods/taxCalc"; -MethodHooks.after("taxes/calculate", function (options) { +function linesToTaxes(lines) { + const taxes = lines.map((line) => { + return { + lineNumber: line.lineNumber, + discountAmount: line.discountAmount, + taxable: line.isItemTaxable, + tax: line.tax, + taxableAmount: line.taxableAmount, + taxCode: line.taxCode, + details: line.details + }; + }); + return taxes; +} + + +MethodHooks.after("taxes/calculate", (options) => { const cartId = options.arguments[0]; const cartToCalc = Cart.findOne(cartId); const pkg = taxCalc.getPackageData(); Logger.debug("Avalara triggered on taxes/calculate for cartId:", cartId); - if (pkg && pkg.settings.avalara.enabled) { + if (pkg && pkg.settings.avalara.enabled && pkg.settings.avalara.performTaxCalculation) { taxCalc.estimateCart(cartToCalc, function (result) { + const taxes = linesToTaxes(result.lines); if (result && result.totalTax && typeof result.totalTax === "number") { - const taxAmount = parseFloat(result.totalTax); + // we don't use totalTax, that just tells us we have a valid tax calculation + const taxAmount = taxes.reduce((totalTaxes, tax) => totalTaxes + tax.tax, 0); const taxRate = taxAmount / taxCalc.calcTaxable(cartToCalc); - Meteor.call("taxes/setRate", cartId, taxRate); + Meteor.call("taxes/setRate", cartId, taxRate, taxes); } }); } return options; }); -MethodHooks.after("cart/copyCartToOrder", function (options) { +MethodHooks.after("cart/copyCartToOrder", (options) => { const pkg = taxCalc.getPackageData(); - if (pkg && pkg.settings.avalara.enabled) { + if (pkg && pkg.settings.avalara.enabled && pkg.settings.avalara.performTaxCalculation) { const cartId = options.arguments[0]; const order = Orders.findOne({ cartId: cartId }); taxCalc.recordOrder(order, function (result) { @@ -37,7 +55,7 @@ MethodHooks.after("cart/copyCartToOrder", function (options) { MethodHooks.after("orders/refunds/create", (options) => { const pkg = taxCalc.getPackageData(); - if (pkg && pkg.settings.avalara.enabled) { + if (pkg && pkg.settings.avalara.enabled && pkg.settings.avalara.performTaxCalculation) { const orderId = options.arguments[0]; const order = Orders.findOne(orderId); const refundAmount = options.arguments[2]; diff --git a/imports/plugins/included/taxes-avalara/server/i18n/en.json b/imports/plugins/included/taxes-avalara/server/i18n/en.json index 79d887bd66b..a067b584ee4 100644 --- a/imports/plugins/included/taxes-avalara/server/i18n/en.json +++ b/imports/plugins/included/taxes-avalara/server/i18n/en.json @@ -4,6 +4,32 @@ "ns": "taxes-avalara", "translation": { "reaction-taxes": { + "settings": { + "apiLoginId": "Avalara API Login ID", + "username": "Username", + "password": "Password", + "companyCode": "Company Code", + "shippingTaxCode": "Shipping Tax Code", + "mode": "Production Mode", + "performTaxCalculation": "Perform Tax Calculation", + "addressValidation": "Address Validation", + "enableAddressValidationByCountry": "Enable Address Validation by Country", + "enableLogging": "Enable Logging", + "logRetentionDuration": "Log Retention Duration", + "requestTimeout": "Request Timeout", + "commitDocuments": "Commit Documents", + "administratorsPanel": "Administrator's Panel", + "testCredentialsSuccess": "Connection Test Success", + "testCredentialsFailed": "Connection Test Failed, Check credentials", + "apiError": "Avalara not configured correctly" + }, + "logGrid": { + "noLogsFound": "No logs found", + "columns": { + "date": "Date", + "docType": "Document Type" + } + }, "admin": { "shortcut": { "avalaraLabel": "Avalara", @@ -12,13 +38,14 @@ "dashboard": { "avalaraLabel": "Avalara", "avalaraTitle": "Avalara", - "avalaraDescription": "Avalara Tax Rates" + "avalaraDescription": "Avalara Tax Rates", + "testCredentials": "Test Credentials" }, "taxSettings": { "avalaraLabel": "Avalara", "avalaraSettingsLabel": "Avalara", - "avalaraCredentials": "Add credentials to enable", - "avalaraGetCredentialsURL": "Get them here" + "formTitle": "Tax Settings", + "entityCodeCustomLabel": "SET CUSTOM VALUE" } } } diff --git a/imports/plugins/included/taxes-avalara/server/index.js b/imports/plugins/included/taxes-avalara/server/index.js index 68dce60357a..a866f0d3fbe 100644 --- a/imports/plugins/included/taxes-avalara/server/index.js +++ b/imports/plugins/included/taxes-avalara/server/index.js @@ -1,2 +1,6 @@ import "./hooks"; import "./i18n"; +import "./jobs/cleanup"; +import cleanupAvalogs from "./jobs/cleanup"; + +cleanupAvalogs(); diff --git a/imports/plugins/included/taxes-avalara/server/jobs/cleanup.js b/imports/plugins/included/taxes-avalara/server/jobs/cleanup.js new file mode 100644 index 00000000000..86d7ccb5fd2 --- /dev/null +++ b/imports/plugins/included/taxes-avalara/server/jobs/cleanup.js @@ -0,0 +1,69 @@ +import moment from "moment"; +import { Meteor } from "meteor/meteor"; +import { Jobs, Logs } from "/lib/collections"; +import { Hooks, Logger } from "/server/api"; +import taxCalc from "../methods/taxCalc"; + + +/** + * @summary Remove logs older than the configured number of days + * @param {Function} callback - function to call when process complete + * @returns {Number} results of remmoval query + */ +function cleanupAvalaraJobs(callback) { + const pkgData = taxCalc.getPackageData(); + if (pkgData && pkgData.settings.avalara.enabled) { + const saveDuration = pkgData.settings.avalara.logRetentionDuration; + const olderThan = moment().subtract(saveDuration, "days"); + const result = Logs.remove({ + date: { + $lt: olderThan + } + }); + Logger.debug(`Removed ${result} Avalara log records`); + } + callback(); +} + + +Hooks.Events.add("afterCoreInit", () => { + if (!Meteor.isAppTest) { + Logger.debug("Adding Avalara log cleanup job and removing existing"); + // Renove all previous jobs + Jobs.remove({ type: "logs/removeOldAvalaraLogs" }); + new Job(Jobs, "logs/removeOldAvalaraLogs", {}) + .priority("normal") + .retry({ + retries: 5, + wait: 60000, + backoff: "exponential" + }) + .save({ + cancelRepeats: true + }); + } +}); + + +export default function () { + Jobs.processJobs("logs/removeOldAvalaraLogs", + { + pollInterval: 30 * 1000, + workTimeout: 180 * 1000 + }, + (job, callback) => { + Logger.debug("Avalara log cleanup running"); + cleanupAvalaraJobs(function (error) { + if (error) { + job.done(error.toString(), { repeatId: true }); + callback(); + } else { + const success = "Avalara Log Cleanup ran successfully"; + Logger.debug(success); + job.done(success, { repeatId: true }); + callback(); + } + }); + } + ); +} diff --git a/imports/plugins/included/taxes-avalara/server/methods/avalogger.js b/imports/plugins/included/taxes-avalara/server/methods/avalogger.js new file mode 100644 index 00000000000..982f831a5d3 --- /dev/null +++ b/imports/plugins/included/taxes-avalara/server/methods/avalogger.js @@ -0,0 +1,43 @@ +import bunyan from "bunyan"; +import { Logs } from "/lib/collections"; +import { Reaction } from "/server/api"; + +const level = "INFO"; + +class BunyanMongo { + + levelToName = { + 10: "trace", + 20: "debug", + 30: "info", + 40: "warn", + 50: "error", + 60: "fatal" + }; + + write = Meteor.bindEnvironment((logData) => { + const avalog = { + logType: "avalara", + shopId: Reaction.getShopId(), + data: logData, + level: this.levelToName[logData.level] + }; + Logs.insert(avalog); + }); +} + +const streams = [ + { + type: "raw", + stream: new BunyanMongo() + } +]; + + +const Avalogger = bunyan.createLogger({ + level, + name: "Avalara", + streams +}); + +export default Avalogger; diff --git a/imports/plugins/included/taxes-avalara/server/methods/taxCalc.js b/imports/plugins/included/taxes-avalara/server/methods/taxCalc.js index 27c553b58ec..9c5e0271878 100644 --- a/imports/plugins/included/taxes-avalara/server/methods/taxCalc.js +++ b/imports/plugins/included/taxes-avalara/server/methods/taxCalc.js @@ -1,12 +1,16 @@ import _ from "lodash"; +import accounting from "accounting-js"; +import os from "os"; import moment from "moment"; import { Meteor } from "meteor/meteor"; import { HTTP } from "meteor/http"; -import { check, Match } from "meteor/check"; -import { Packages, Shops } from "/lib/collections"; -import { Reaction } from "/server/api"; +import { check } from "meteor/check"; +import { Packages, Shops, Accounts } from "/lib/collections"; +import { Reaction, Logger } from "/server/api"; +import Avalogger from "./avalogger"; const countriesWithRegions = ["US", "CA", "DE", "AU"]; +const requiredFields = ["username", "password", "apiLoginId", "companyCode", "shippingTaxCode"]; const taxCalc = {}; taxCalc.getPackageData = function () { @@ -36,22 +40,172 @@ function getUrl() { return baseUrl; } +/** + * @summary Verify that we have all required configuration data before attempting to use the API + * @param {Object} packageData - Package data retrieved from the database + * @returns {boolean} - isValid Is the current configuration valid + */ +function checkConfiguration(packageData = taxCalc.getPackageData()) { + let isValid = true; + const settings = _.get(packageData, "settings.avalara", {}); + for (const field of requiredFields) { + if (!settings[field]) { + const msg = `The Avalara package cannot function unless ${field} is configured`; + Logger.fatal(msg); + Avalogger.error({ error: msg }); + isValid = false; + } + } + if (!isValid) { + throw new Meteor.Error("The Avalara package is not configured correctly. Cannot continue"); + } + return isValid; +} + /** * @summary Get the auth info to authenticate to REST API + * @param {Object} packageData - Optionally pass in packageData if we already have it * @returns {String} Username/Password string */ -function getAuthData() { - const packageData = taxCalc.getPackageData(); - const { username, password } = packageData.settings.avalara; +function getAuthData(packageData = taxCalc.getPackageData()) { + if (checkConfiguration(packageData)) { + const settings = _.get(packageData, "settings.avalara", {}); + const { username, password } = settings; + const auth = `${username}:${password}`; + return auth; + } +} + +/** + * @summary Get exempt tax settings to pass to REST API + * @param {String} userId id of user to find settings + * @returns {Object} containing exemptCode and customerUsageType + */ +function getTaxSettings(userId) { + return _.get(Accounts.findOne({ _id: userId }), "taxSettings"); +} + +/** + * @summary function to get HTTP data and pass in extra Avalara-specific headers + * @param {String} requestUrl - The URL to make the request to + * @param {Object} options - An object of other options + * @param {Boolean} testCredentials - determines skipping of configuration check + * @returns {Object} Response from call + */ +function avaGet(requestUrl, options = {}, testCredentials = true) { + const logObject = {}; + const pkgData = taxCalc.getPackageData(); + + if (testCredentials) { + if (!checkConfiguration(pkgData)) { + return undefined; + } + } + + const appVersion = Reaction.getAppVersion(); + const meteorVersion = _.split(Meteor.release, "@")[1]; + const machineName = os.hostname(); + const avaClient = `Reaction; ${appVersion}; Meteor HTTP; ${meteorVersion}; ${machineName}`; + const headers = { + headers: { + "X-Avalara-Client": avaClient, + "X-Avalara-UID": "a0o33000004K8g3" + } + }; + const auth = options.auth || getAuthData(); + const timeout = { timeout: options.timeout || pkgData.settings.avalara.requestTimeout }; + const allOptions = Object.assign({}, options, headers, { auth }, timeout); + if (pkgData.settings.avalara.enableLogging) { + logObject.request = allOptions; + } + + try { + result = HTTP.get(requestUrl, allOptions); + } catch (error) { + result = error; + Logger.error(`Encountered error while calling Avalara API endpoint ${requestUrl}`); + Logger.error(error); + logObject.error = error; + Avalogger.error(logObject); + } + + if (pkgData.settings.avalara.enableLogging) { + logObject.duration = _.get(result, "headers.serverDuration"); + logObject.result = result.data; + Avalogger.info(logObject); + } + + return result; +} + + +/** + * @summary to POST HTTP data and pass in extra Avalara-specific headers + * @param {String} requestUrl - The URL to make the request to + * @param {Object} options - An object of others options, usually data + * @returns {Object} Response from call + */ +function avaPost(requestUrl, options) { + const logObject = {}; + const pkgData = taxCalc.getPackageData(); + const appVersion = Reaction.getAppVersion(); + const meteorVersion = _.split(Meteor.release, "@")[1]; + const machineName = os.hostname(); + const avaClient = `Reaction; ${appVersion}; Meteor HTTP; ${meteorVersion}; ${machineName}`; + const headers = { + headers: { + "X-Avalara-Client": avaClient, + "X-Avalara-UID": "a0o33000004K8g3" + } + }; + const auth = { auth: getAuthData() }; + const timeout = { timeout: pkgData.settings.avalara.requestTimeout }; + const allOptions = Object.assign({}, options, headers, auth, timeout); + if (pkgData.settings.avalara.enableLogging) { + logObject.request = allOptions; + } + + let result; - if (!username || !password) { - throw new Meteor.Error("You cannot use this API without a username and password configured"); + try { + result = HTTP.post(requestUrl, allOptions); + } catch (error) { + Logger.error(`Encountered error while calling API at ${requestUrl}`); + Logger.error(error); + logObject.error = error; + // whether logging is enabled or not we log out errors + Avalogger.error(logObject); + result = {}; } - const auth = `${username}:${password}`; - return auth; + if (pkgData.settings.avalara.enableLogging) { + logObject.duration = _.get(result, "headers.serverDuration"); + logObject.result = result.data; + Avalogger.info(logObject); + } + + return result; } +/** + * @summary Gets the full list of Avalara-supported entity use codes. + * @returns {Object[]} API response + */ +taxCalc.getEntityCodes = function () { + if (checkConfiguration()) { + const baseUrl = getUrl(); + const requestUrl = `${baseUrl}definitions/entityusecodes`; + const result = avaGet(requestUrl); + + if (result && result.code === "ETIMEDOUT") { + throw new Meteor.Error("Request timed out while populating entity codes."); + } + + return _.get(result, "data.value", []); + } + throw new Meteor.Error("bad-configuration", "Avalara package is enabled, but is not properly configured"); +}; + // API Methods /** @@ -69,24 +223,6 @@ taxCalc.calcTaxable = function (cart) { return subTotal; }; -/** - * @summary Get the company code from the db - * @returns {String} Company Code - */ -taxCalc.getCompanyCode = function () { - const result = Packages.findOne({ - name: "taxes-avalara", - shopId: Reaction.getShopId(), - enabled: true - }, { fields: { "settings.avalara.companyCode": 1 } }); - const companyCode = result.settings.avalara.companyCode; - if (companyCode) { - return companyCode; - } - const savedCompanyCode = taxCalc.saveCompanyCode(); - return savedCompanyCode; -}; - /** * @summary Validate a particular address * @param {Object} address Address to validate @@ -95,8 +231,17 @@ taxCalc.getCompanyCode = function () { taxCalc.validateAddress = function (address) { check(address, Object); + const packageData = taxCalc.getPackageData(); + const { countryList } = packageData.settings.addressValidation; + + if (!_.includes(countryList, address.country)) { + // if this is a country selected for validation, proceed + // else use current address as response + return { validatedAddress: address, errors: [] }; + } + let messages; - let validatedAddress; + let validatedAddress = ""; // set default as falsy value const errors = []; const addressToValidate = { line1: address.address1, @@ -112,12 +257,17 @@ taxCalc.validateAddress = function (address) { if (address.line2) { addressToValidate.line2 = address.address2; } - const auth = getAuthData(); const baseUrl = getUrl(); - const requestUrl = `${baseUrl}/addresses/resolve`; - const result = HTTP.post(requestUrl, { data: addressToValidate, auth: auth }); - const content = JSON.parse(result.content); - if (content.messages) { + const requestUrl = `${baseUrl}addresses/resolve`; + const result = avaPost(requestUrl, { data: addressToValidate }); + let content; + + try { + content = JSON.parse(result.content); + } catch (error) { + content = result.content; + } + if (content && content.messages) { messages = content.messages; } if (messages) { @@ -143,37 +293,38 @@ taxCalc.validateAddress = function (address) { }; /** - * @summary Get all registered companies - * @param {Function} callback Callback function for asynchronous execution - * @returns {Object} A list of all companies + * @summary Tests supplied Avalara credentials by calling company endpoint + * @param {Object} credentials callback Callback function for asynchronous execution + * @param {Boolean} testCredentials To be set as false so avaGet skips config check + * @returns {Object} Object containing "statusCode" on success, empty response on error */ -taxCalc.getCompanies = function (callback) { - const auth = getAuthData(); +taxCalc.testCredentials = function (credentials, testCredentials = false) { + check(credentials, Object); + const baseUrl = getUrl(); - const requestUrl = `${baseUrl}/companies`; + const auth = `${credentials.username}:${credentials.password}`; + const requestUrl = `${baseUrl}companies/${credentials.companyCode}/transactions`; + const result = avaGet(requestUrl, { auth, timeout: credentials.requestTimeout }, testCredentials); - if (callback) { - HTTP.get(requestUrl, { auth: auth }, (err, result) => { - return (callback(result)); - }); - } else { - const result = HTTP.get(requestUrl, { auth: auth }); - return result; + if (result && result.code === "ETIMEDOUT") { + throw new Meteor.Error("Request Timed out. Increase your timeout settings"); } + + return { statusCode: result.statusCode }; }; /** - * @summary Fetch the company code from the API and save in the DB - * @returns {String} Company code + * @summary get Avalara Tax Codes + * @returns {Array} An array of Tax code objects */ -taxCalc.saveCompanyCode = function () { - const companyData = taxCalc.getCompanies(); - const companyCode = companyData.data.value[0].companyCode; - const packageData = taxCalc.getPackageData(); - Packages.update({ _id: packageData._id }, { - $set: { "settings.avalara.companyCode": companyCode } - }); - return companyCode; +taxCalc.getTaxCodes = function () { + if (checkConfiguration()) { + const baseUrl = getUrl(); + const requestUrl = `${baseUrl}definitions/taxcodes`; + const result = avaGet(requestUrl); + return _.get(result, "data.value", []); + } + throw new Meteor.Error("bad-configuration", "Avalara Tax package is enabled but not properly configured"); }; /** @@ -182,20 +333,37 @@ taxCalc.saveCompanyCode = function () { * @returns {Object} SalesOrder in Avalara format */ function cartToSalesOrder(cart) { - const companyCode = taxCalc.getCompanyCode(); + const pkgData = taxCalc.getPackageData(); + const { companyCode, shippingTaxCode } = pkgData.settings.avalara; const company = Shops.findOne(Reaction.getShopId()); const companyShipping = _.filter(company.addressBook, (o) => o.isShippingDefault)[0]; const currencyCode = company.currency; + const cartShipping = cart.cartShipping(); + const cartDate = moment(cart.createdAt).format(); let lineItems = []; if (cart.items) { - lineItems = cart.items.map((item, index) => { - return { - number: _.toString(index + 1), - quantity: item.quantity, - amount: item.variants.price * item.quantity, - description: item.title - }; + lineItems = cart.items.map((item) => { + if (item.variants.taxable) { + return { + number: item._id, + itemCode: item.productId, + quantity: item.quantity, + amount: item.variants.price * item.quantity, + description: item.taxDescription || item.title, + taxCode: item.variants.taxCode + }; + } }); + if (cartShipping) { + lineItems.push({ + number: "shipping", + itemCode: "shipping", + quantity: 1, + amount: cartShipping, + description: "Shipping", + taxCode: shippingTaxCode + }); + } } const salesOrder = { @@ -203,7 +371,7 @@ function cartToSalesOrder(cart) { type: "SalesOrder", code: cart._id, customerCode: cart.userId, - date: moment.utc(cart.createdAt), + date: cartDate, currencyCode: currencyCode, addresses: { ShipFrom: { @@ -224,6 +392,17 @@ function cartToSalesOrder(cart) { }, lines: lineItems }; + + // current "coupon code" discount are based at the cart level, and every iten has it's + // discounted property set to true. + if (cart.discount) { + salesOrder.discount = accounting.toFixed(cart.discount, 2); + for (const line of salesOrder.lines) { + if (line.itemCode !== "shipping") { + line.discounted = true; + } + } + } return salesOrder; } @@ -235,22 +414,14 @@ function cartToSalesOrder(cart) { */ taxCalc.estimateCart = function (cart, callback) { check(cart, Reaction.Schemas.Cart); - check(callback, Match.Optional(Function)); + check(callback, Function); if (cart.items && cart.shipping && cart.shipping[0].address) { - const salesOrder = cartToSalesOrder(cart); - const auth = getAuthData(); + const salesOrder = Object.assign({}, cartToSalesOrder(cart), getTaxSettings(cart.userId)); const baseUrl = getUrl(); - const requestUrl = `${baseUrl}/transactions/create`; - if (callback) { - HTTP.post(requestUrl, { data: salesOrder, auth: auth }, (err, result) => { - const data = JSON.parse(result.content); - return callback(data); - }); - } - const result = HTTP.post(requestUrl, { data: salesOrder, auth: auth }); - const data = JSON.parse(result.content); - return data; + const requestUrl = `${baseUrl}transactions/create`; + const result = avaPost(requestUrl, { data: salesOrder }); + return callback(result.data); } }; @@ -260,26 +431,49 @@ taxCalc.estimateCart = function (cart, callback) { * @returns {Object} SalesOrder in Avalara format */ function orderToSalesInvoice(order) { - const companyCode = taxCalc.getCompanyCode(); + let documentType; + const pkgData = taxCalc.getPackageData(); + const { companyCode, shippingTaxCode, commitDocuments } = pkgData.settings.avalara; + if (commitDocuments) { + documentType = "SalesInvoice"; + } else { + documentType = "SalesOrder"; + } const company = Shops.findOne(Reaction.getShopId()); const companyShipping = _.filter(company.addressBook, (o) => o.isShippingDefault)[0]; const currencyCode = company.currency; - const lineItems = order.items.map((item, index) => { - return { - number: _.toString(index + 1), - quantity: item.quantity, - amount: item.variants.price * item.quantity, - description: item.title - }; + const orderShipping = order.orderShipping(); + const orderDate = moment(order.createdAt).format(); + const lineItems = order.items.map((item) => { + if (item.variants.taxable) { + return { + number: item._id, + itemCode: item.productId, + quantity: item.quantity, + amount: item.variants.price * item.quantity, + description: item.taxDescription || item.title, + taxCode: item.variants.taxCode + }; + } }); + if (orderShipping) { + lineItems.push({ + number: "shipping", + itemCode: "shipping", + quantity: 1, + amount: orderShipping, + description: "Shipping", + taxCode: shippingTaxCode + }); + } const salesInvoice = { companyCode: companyCode, - type: "SalesInvoice", - commit: true, + type: documentType, + commit: commitDocuments, code: order.cartId, customerCode: order.userId, - date: moment.utc(order.createdAt), + date: orderDate, currencyCode: currencyCode, addresses: { ShipFrom: { @@ -300,6 +494,15 @@ function orderToSalesInvoice(order) { }, lines: lineItems }; + + if (order.discount) { + salesInvoice.discount = accounting.toFixed(order.discount, 2); + for (const line of salesInvoice.lines) { + if (line.itemCode !== "shipping") { + line.discounted = true; + } + } + } return salesInvoice; } @@ -311,21 +514,14 @@ function orderToSalesInvoice(order) { */ taxCalc.recordOrder = function (order, callback) { check(callback, Function); + // unlike the other functions, we expect this to always be called asynchronously if (order && order.shipping && order.shipping[0].address) { - const salesOrder = orderToSalesInvoice(order); - const auth = getAuthData(); + const salesOrder = Object.assign({}, orderToSalesInvoice(order), getTaxSettings(order.userId)); const baseUrl = getUrl(); - const requestUrl = `${baseUrl}/transactions/create`; - + const requestUrl = `${baseUrl}transactions/create`; try { - HTTP.post(requestUrl, { data: salesOrder, auth: auth }, (err, result) => { - if (err) { - Logger.error("Encountered error while recording order to Avalara"); - Logger.error(err); - } - const data = JSON.parse(result.content); - return callback(data); - }); + const result = avaPost(requestUrl, { data: salesOrder }); + return callback(result.data); } catch (error) { Logger.error("Encountered error while recording order to Avalara"); Logger.error(error); @@ -343,14 +539,17 @@ taxCalc.recordOrder = function (order, callback) { taxCalc.reportRefund = function (order, refundAmount, callback) { check(refundAmount, Number); check(callback, Function); + const pkgData = taxCalc.getPackageData(); + const { companyCode } = pkgData.settings.avalara; const company = Shops.findOne(Reaction.getShopId()); const companyShipping = _.filter(company.addressBook, (o) => o.isShippingDefault)[0]; const currencyCode = company.currency; - const companyCode = taxCalc.getCompanyCode(); - const auth = getAuthData(); const baseUrl = getUrl(); - const requestUrl = `${baseUrl}/transactions/create`; + const requestUrl = `${baseUrl}transactions/create`; const returnAmount = refundAmount * -1; + const orderDate = moment(order.createdAt).format(); + const refundDate = moment().format(); + const refundReference = `${order.cartId}:${refundDate}`; const lineItems = { number: "01", quantity: 1, @@ -360,11 +559,11 @@ taxCalc.reportRefund = function (order, refundAmount, callback) { const returnInvoice = { companyCode: companyCode, type: "ReturnInvoice", - code: order.cartId, + code: refundReference, commit: true, customerCode: order._id, - taxDate: moment.utc(order.createdAt), - date: moment(), + taxDate: orderDate, + date: refundDate, currencyCode: currencyCode, addresses: { ShipFrom: { @@ -386,19 +585,16 @@ taxCalc.reportRefund = function (order, refundAmount, callback) { lines: [lineItems] }; - if (callback) { - HTTP.post(requestUrl, { data: returnInvoice, auth: auth }, (err, result) => { - const data = JSON.parse(result.content); - return callback(data); - }); - } - const result = HTTP.post(requestUrl, { data: returnInvoice, auth: auth }); - const data = JSON.parse(result.content); - return data; + + const result = avaPost(requestUrl, { data: returnInvoice }); + return callback(result.data); }; export default taxCalc; Meteor.methods({ - "avalara/addressValidation": taxCalc.validateAddress + "avalara/addressValidation": taxCalc.validateAddress, + "avalara/getTaxCodes": taxCalc.getTaxCodes, + "avalara/testCredentials": taxCalc.testCredentials, + "avalara/getEntityCodes": taxCalc.getEntityCodes }); diff --git a/imports/plugins/included/taxes-taxcloud/register.js b/imports/plugins/included/taxes-taxcloud/register.js index 763fb57a73a..82c23046e4a 100644 --- a/imports/plugins/included/taxes-taxcloud/register.js +++ b/imports/plugins/included/taxes-taxcloud/register.js @@ -12,6 +12,9 @@ Reaction.registerPackage({ apiKey: "", refreshPeriod: "every 7 days", taxCodeUrl: "https://taxcloud.net/tic/?format=json" + }, + taxCodes: { + getTaxCodeMethod: "taxcloud/getTaxCodes" } }, registry: [ @@ -20,6 +23,11 @@ Reaction.registerPackage({ name: "taxes/settings/taxcloud", provides: "taxSettings", template: "taxCloudSettings" + }, + { + label: "TaxCloud Tax Codes", + provides: "taxCodes", + name: "taxes/taxcodes/taxcloud" } ] }); diff --git a/imports/plugins/included/taxes-taxcloud/server/jobs/taxcodes.js b/imports/plugins/included/taxes-taxcloud/server/jobs/taxcodes.js index 104901610d0..700da632eb7 100644 --- a/imports/plugins/included/taxes-taxcloud/server/jobs/taxcodes.js +++ b/imports/plugins/included/taxes-taxcloud/server/jobs/taxcodes.js @@ -22,8 +22,8 @@ Hooks.Events.add("afterCoreInit", () => { // set 0 to disable fetchTIC if (refreshPeriod !== 0) { - Logger.debug(`Adding taxes/fetchTIC to JobControl. Refresh ${refreshPeriod}`); - new Job(Jobs, "taxes/fetchTaxCloudTaxCodes", { url: taxCodeUrl }) + Logger.debug(`Adding taxcloud/getTaxCodes to JobControl. Refresh ${refreshPeriod}`); + new Job(Jobs, "taxcloud/getTaxCodes", { url: taxCodeUrl }) .priority("normal") .retry({ retries: 5, @@ -48,13 +48,13 @@ Hooks.Events.add("afterCoreInit", () => { // export default function () { Jobs.processJobs( - "taxes/fetchTaxCloudTaxCodes", + "taxcloud/getTaxCodes", { pollInterval: 30 * 1000, workTimeout: 180 * 1000 }, (job, callback) => { - Meteor.call("taxes/fetchTIC", error => { + Meteor.call("taxcloud/getTaxCodes", error => { if (error) { if (error.error === "notConfigured") { Logger.warn(error.message); diff --git a/imports/plugins/included/taxes-taxcloud/server/methods/methods.js b/imports/plugins/included/taxes-taxcloud/server/methods/methods.js index 714afe829b3..83360344772 100644 --- a/imports/plugins/included/taxes-taxcloud/server/methods/methods.js +++ b/imports/plugins/included/taxes-taxcloud/server/methods/methods.js @@ -5,6 +5,7 @@ import { EJSON } from "meteor/ejson"; import { Logger } from "/server/api"; import Reaction from "../../core/taxes/server/api"; + Meteor.methods({ /** * taxes/fetchTIC @@ -17,7 +18,7 @@ Meteor.methods({ * @param {String} url alternate url to fetch TaxCodes from * @return {undefined} */ - "taxes/fetchTIC": function (url) { + "taxcloud/getTaxCodes": function (url) { check(url, Match.Optional(String)); // check(url, Match.Optional(SimpleSchema.RegEx.Url)); diff --git a/lib/collections/collections.js b/lib/collections/collections.js index c05da348e22..d83e8710d06 100644 --- a/lib/collections/collections.js +++ b/lib/collections/collections.js @@ -1,6 +1,7 @@ import { Mongo } from "meteor/mongo"; import * as Schemas from "./schemas"; import { cartTransform } from "./transform/cart"; +import { orderTransform } from "./transform/order"; /** * @@ -68,16 +69,8 @@ Inventory.attachSchema(Schemas.Inventory); */ export const Orders = new Mongo.Collection("Orders", { transform(order) { - order.itemCount = () => { - let count = 0; - if (order && Array.isArray(order.items)) { - for (const items of order.items) { - count += items.quantity; - } - } - return count; - }; - return order; + const newInstance = Object.create(orderTransform); + return _.extend(newInstance, order); } }); @@ -172,3 +165,11 @@ Notifications.attachSchema(Schemas.Notification); export const Sms = new Mongo.Collection("Sms"); Sms.attachSchema(Schemas.Sms); + + +/** + * Logs Collection + */ +export const Logs = new Mongo.Collection("Logs"); + +Logs.attachSchema(Schemas.Logs); diff --git a/lib/collections/schemas/accounts.js b/lib/collections/schemas/accounts.js index 2dca29213f7..de349b99b93 100644 --- a/lib/collections/schemas/accounts.js +++ b/lib/collections/schemas/accounts.js @@ -7,6 +7,17 @@ import { Metafield } from "./metafield"; * Accounts Schemas */ +const TaxSettings = new SimpleSchema({ + exemptionNo: { + type: String, + optional: true + }, + customerUsageType: { + type: String, + optional: true + } +}); + export const Profile = new SimpleSchema({ addressBook: { type: [Address], @@ -75,6 +86,10 @@ export const Accounts = new SimpleSchema({ defaultValue: "new", optional: true }, + taxSettings: { + type: TaxSettings, + optional: true + }, note: { type: String, optional: true diff --git a/lib/collections/schemas/index.js b/lib/collections/schemas/index.js index 2c73c562611..0419b283069 100644 --- a/lib/collections/schemas/index.js +++ b/lib/collections/schemas/index.js @@ -6,6 +6,7 @@ export * from "./cart"; export * from "./emails"; export * from "./inventory"; export * from "./layouts"; +export * from "./logs"; export * from "./metafield"; export * from "./notifications"; export * from "./orders"; diff --git a/lib/collections/schemas/logs.js b/lib/collections/schemas/logs.js new file mode 100644 index 00000000000..cfbc524276a --- /dev/null +++ b/lib/collections/schemas/logs.js @@ -0,0 +1,24 @@ +import { SimpleSchema } from "meteor/aldeed:simple-schema"; + +export const Logs = new SimpleSchema({ + + logType: { + type: String + }, + shopId: { + type: String + }, + level: { + type: String, + defaultValue: "info", + allowedValues: ["trace", "debug", "info", "warn", "error", "fatal"] + }, + data: { + type: Object, + blackbox: true + }, + date: { + type: Date, + autoValue() { return new Date(); } + } +}); diff --git a/lib/collections/schemas/products.js b/lib/collections/schemas/products.js index 94cf1f24c26..15ffbf24275 100644 --- a/lib/collections/schemas/products.js +++ b/lib/collections/schemas/products.js @@ -138,6 +138,24 @@ export const ProductVariant = new SimpleSchema({ } } }, + length: { + label: "Length", + type: Number, + min: 0, + optional: true + }, + width: { + label: "Width", + type: Number, + min: 0, + optional: true + }, + height: { + label: "Height", + type: Number, + min: 0, + optional: true + }, inventoryManagement: { type: Boolean, label: "Inventory Tracking", @@ -239,6 +257,11 @@ export const ProductVariant = new SimpleSchema({ defaultValue: "00000", optional: true }, + taxDescription: { + type: String, + optional: true, + label: "Tax Description" + }, // Label for customers title: { label: "Label", @@ -276,6 +299,10 @@ export const ProductVariant = new SimpleSchema({ workflow: { type: Workflow, optional: true + }, + originCountry: { + type: String, + optional: true } }); @@ -329,6 +356,10 @@ export const Product = new SimpleSchema({ type: String, optional: true }, + originCountry: { + type: String, + optional: true + }, type: { label: "Type", type: String, diff --git a/lib/collections/schemas/shops.js b/lib/collections/schemas/shops.js index 262884b262a..b689d0bf8a3 100644 --- a/lib/collections/schemas/shops.js +++ b/lib/collections/schemas/shops.js @@ -238,6 +238,10 @@ export const Shop = new SimpleSchema({ type: [BrandAsset], optional: true }, + "appVersion": { + type: String, + optional: true + }, "createdAt": { type: Date, autoValue: function () { diff --git a/lib/collections/transform/order.js b/lib/collections/transform/order.js new file mode 100644 index 00000000000..8930dbc6f90 --- /dev/null +++ b/lib/collections/transform/order.js @@ -0,0 +1,96 @@ +import accounting from "accounting-js"; + + +// TODO: This is a duplicate of the cart transform with just the names changed. +// This should be factored to be just one file for both + +/** + * getSummary + * @summary iterates over order items with computations + * @param {Array} items - order.items array + * @param {Array} prop - path to item property represented by array + * @param {Array} [prop2] - path to another item property represented by array + * @return {Number} - computations result + */ +function getSummary(items, prop, prop2) { + try { + if (Array.isArray(items)) { + return items.reduce((sum, item) => { + if (prop2) { + // S + a * b, where b could be b1 or b2 + return sum + item[prop[0]] * (prop2.length === 1 ? item[prop2[0]] : + item[prop2[0]][prop2[1]]); + } + // S + b, where b could be b1 or b2 + return sum + (prop.length === 1 ? item[prop[0]] : + item[prop[0]][prop[1]]); + }, 0); + } + } catch (e) { + // If data not prepared we should send a number to avoid exception with + // `toFixed`. This could happens if user stuck on `completed` checkout stage + // by some reason. + return 0; + } + return 0; +} + +/** + * Reaction transform collections + * + * transform methods used to return order calculated values + * orderCount, orderSubTotal, orderShipping, orderTaxes, orderTotal + * are calculated by a transformation on the collection + * and are available to use in template as order.xxx + * in template: {{order.orderCount}} + * in code: order.findOne().orderTotal() + */ +export const orderTransform = { + orderCount() { + return getSummary(this.items, ["quantity"]); + }, + orderShipping() { + // loop through the order.shipping, sum shipments. + const rate = getSummary(this.shipping, ["shipmentMethod", "rate"]); + const handling = getSummary(this.shipping, ["shipmentMethod", "handling"]); + const shipping = handling + rate || 0; + return accounting.toFixed(shipping, 2); + }, + orderSubTotal() { + const subTotal = getSummary(this.items, ["quantity"], ["variants", "price"]); + return accounting.toFixed(subTotal, 2); + }, + orderTaxes() { + // taxes are calculated in a order.after.update hooks + // the tax value stored with the order is the effective tax rate + // calculated by line items + // in the imports/core/taxes plugin + const tax = this.tax || 0; + const subTotal = parseFloat(this.orderSubTotal()); + const taxTotal = subTotal * tax; + return accounting.toFixed(taxTotal, 2); + }, + orderDiscounts() { + const discount = this.discount || 0; + return accounting.toFixed(discount, 2); + }, + orderTotal() { + const subTotal = parseFloat(this.orderSubTotal()); + const shipping = parseFloat(this.orderShipping()); + const taxes = parseFloat(this.orderTaxes()); + const discount = parseFloat(this.orderDiscounts()); + const discountTotal = Math.max(0, subTotal - discount); + const total = discountTotal + shipping + taxes; + return accounting.toFixed(total, 2); + }, + itemCount() { + let count = 0; + if (Array.isArray(this.items)) { + for (const item of this.items) { + count += item.quantity; + } + } + return count; + } +}; + diff --git a/private/data/i18n/en.json b/private/data/i18n/en.json index fc999eae5cd..f26ca153416 100644 --- a/private/data/i18n/en.json +++ b/private/data/i18n/en.json @@ -231,6 +231,7 @@ "pageTitle": "Subtitle", "vendor": "Vendor", "description": "Description", + "originCountry": "Origin Country", "facebookMsg": "Facebook Message", "twitterMsg": "Twitter Message", "pinterestMsg": "Pinterest Message", @@ -277,13 +278,20 @@ "compareAtPrice": "Price (MSRP)", "fulfillmentService": "Fulfillment service", "weight": "Weight", + "length": "Length", + "width": "Width", + "height": "Height", "taxable": "Taxable", + "taxCode": "Tax Codes", + "taxDescription": "Tax Description", "inventoryManagement": "Inventory tracking", "inventoryManagementLabel": "Register product in inventory system and track its status", "inventoryPolicy": "Deny when out of stock", "inventoryPolicyLabel": "Do not sell the product variant when it is not in stock", "lowInventoryWarningThreshold": "Warn @", - "lowInventoryWarningThresholdLabel": "Warn customers that the product will be close to sold out when the quantity reaches the next threshold" + "lowInventoryWarningThresholdLabel": "Warn customers that the product will be close to sold out when the quantity reaches the next threshold", + "originCountry": "Origin Country", + "selectTaxCode": "Select Tax Code" }, "variantList": { "moreOptions": "There are more options available for this selection.", diff --git a/server/api/core/core.js b/server/api/core/core.js index a620758645c..dc6f5372d39 100644 --- a/server/api/core/core.js +++ b/server/api/core/core.js @@ -1,4 +1,5 @@ import url from "url"; +import packageJson from "/package.json"; import { merge, uniqWith } from "lodash"; import { Meteor } from "meteor/meteor"; import { EJSON } from "meteor/ejson"; @@ -10,6 +11,7 @@ import { registerTemplate } from "./templates"; import { sendVerificationEmail } from "./accounts"; import { getMailUrl } from "./email/config"; + export default { init() { @@ -30,6 +32,7 @@ export default { this.Import.flush(); // timing is important, packages are rqd for initilial permissions configuration. this.createDefaultAdminUser(); + this.setAppVersion(); // hook after init finished Hooks.Events.run("afterCoreInit"); @@ -207,6 +210,10 @@ export default { return Packages.findOne({ packageName: name, shopId: this.getShopId() }) || null; }, + getAppVersion() { + return Shops.findOne().appVersion; + }, + /** * createDefaultAdminUser * @summary Method that creates default admin user @@ -444,5 +451,10 @@ export default { return false; }); }); + }, + setAppVersion() { + const version = packageJson.version; + Logger.info(`Reaction Version: ${version}`); + Shops.update({}, { $set: { appVersion: version } }, { multi: true }); } }; diff --git a/server/methods/accounts/accounts.js b/server/methods/accounts/accounts.js index d7c3c0b6956..cce0e9bc93c 100644 --- a/server/methods/accounts/accounts.js +++ b/server/methods/accounts/accounts.js @@ -34,6 +34,19 @@ function getValidator() { return !_.includes(coder.name, "reaction"); })[0]; } + + // check if addressValidation is enabled but the package is disabled, don't do address validation + let registryName; + for (const registry of geoCoder.registry) { + if (registry.provides === "addressValidation") { + registryName = registry.name; + } + } + const packageKey = registryName.split("/")[2]; // "taxes/addressValidation/{packageKey}" + if (!_.get(geoCoder.settings[packageKey], "enabled")) { + return ""; + } + const methodName = geoCoder.settings.addressValidation.addressValidationMethod; return methodName; } @@ -52,7 +65,7 @@ function compareAddress(address, validationAddress) { errors.push({ address1: "Address line one did not validate" }); } - if (address.address2 && !validationAddress.address2) { + if (address.address2 && validationAddress.address2 && _.trim(_.upperCase(address.address2)) !== _.trim(_.upperCase(validationAddress.address2))) { errors.push({ address2: "Address line 2 did not validate" }); } diff --git a/server/publications/collections/accounts.js b/server/publications/collections/accounts.js index a921a68ca27..4eedc96567c 100644 --- a/server/publications/collections/accounts.js +++ b/server/publications/collections/accounts.js @@ -44,6 +44,22 @@ Meteor.publish("Accounts", function (userId) { }); }); +/** + * Single account + * @params {String} userId - id of user to find + */ +Meteor.publish("UserAccount", function (userId) { + check(userId, Match.OneOf(String, null)); + + const shopId = Reaction.getShopId(); + if (Roles.userIsInRole(this.userId, ["admin", "owner"], shopId)) { + return Collections.Accounts.find({ + userId: userId + }); + } + return this.ready(); +}); + /** * userProfile * @deprecated since version 0.10.2 diff --git a/server/publications/email.js b/server/publications/email.js index 5f238b781b8..8bee9c50545 100644 --- a/server/publications/email.js +++ b/server/publications/email.js @@ -9,7 +9,7 @@ import { Roles } from "meteor/alanning:roles"; Meteor.publish("Emails", function (query, options) { check(query, Match.Optional(Object)); check(options, Match.Optional(Object)); - + if (Roles.userIsInRole(this.userId, ["owner", "admin", "dashboard"])) { Counts.publish(this, "emails-count", Jobs.find({ type: "sendEmail" })); return Jobs.find({ type: "sendEmail" });