fix: cron jobs and brightpearl auto refresh (#107)

* fix: adds automatic refresh of tokens

* fix: send options to plugin loaders

* fix: adds README optional inventory sync
This commit is contained in:
Sebastian Rindom
2020-09-10 13:33:38 +02:00
committed by GitHub
parent e3d8eea3cd
commit c7bd7838aa
11 changed files with 109 additions and 580 deletions

View File

@@ -12,5 +12,6 @@ yarn.lock
/services
/models
/subscribers
/loaders
/utils

View File

@@ -3,6 +3,7 @@ node_modules
.DS_store
.env*
/*.js
src
!index.js
yarn.lock

View File

@@ -0,0 +1,35 @@
# medusa-plugin-brightpearl
Sends orders to Brightpearl, listens for stock movements, handles returns.
## Options
```
account: [the Brightpearl account] (required)
channel_id: [channel id to map sales and credits to] (required)
backend_url: [the url where the Medusa server is running, needed for webhooks] (required)
event_owner: [the id of the user who will own goods out events] (required),
warehouse: [the warehouse id to allocate orders from] (required)
default_status_id: [the status id to assign new orders with] (optional: defaults to 1)
payment_method_code: [the method code to register payments with] (optional: defaults to 1220)
sales_account_code: [nominal code to assign line items to] (optional: defaults to 4000)
shipping_account_code: [nominal code to assign shipping line to] (optional: defaults to 4040)jk
discount_account_code: [nominal code to use for Discount-type refunds] (optional)
inventory_sync_cron: [cron pattern for inventory sync, if left out the job will not be created] (default: false)
```
## Orders
When an order is created in Medusa it will automatically be sent to Brightpearl and allocated there. Once allocated it is up to Brightpearl to figure out how the order is to be fulfilled - the plugin listens for goods out notes and tries to map each of these to a Medusa order, if the matching succeeds Medusa will send the order to the fulfillment provider associated with the shipping method selected by the Customer.
When line items on an order are returned the plugin will generate a sales credit in Brightpearl.
## Products
The plugin doesn't automatically create products in Medusa, but listens for inventory changes in Brightpearl. The plugin updates each product variant to reflect the inventory quantity listed in Brightpearl, thereby ensuring that the inventory levels in Medusa are always in sync with Brightpearl.
## OAuth
The plugin registers an OAuth app in Medusa allowing installation at https://medusa-commerce.com/a/settings/apps. The OAuth tokens are refreshed every hour to prevent unauthorized requests.

View File

@@ -1,61 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
var inventorySync = /*#__PURE__*/function () {
var _ref = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee(container) {
var brightpearlService, eventBus, client, pattern;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
brightpearlService = container.resolve("brightpearlService");
eventBus = container.resolve("eventBusService");
_context.prev = 2;
_context.next = 5;
return brightpearlService.getClient();
case 5:
client = _context.sent;
pattern = "43 4,10,14,20 * * *"; // nice for tests "*/10 * * * * *"
eventBus.createCronJob("inventory-sync", {}, pattern, brightpearlService.syncInventory());
_context.next = 15;
break;
case 10:
_context.prev = 10;
_context.t0 = _context["catch"](2);
if (!(_context.t0.name === "not_allowed")) {
_context.next = 14;
break;
}
return _context.abrupt("return");
case 14:
throw _context.t0;
case 15:
case "end":
return _context.stop();
}
}
}, _callee, null, [[2, 10]]);
}));
return function inventorySync(_x) {
return _ref.apply(this, arguments);
};
}();
var _default = inventorySync;
exports["default"] = _default;

View File

@@ -1,61 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
var webhookLoader = /*#__PURE__*/function () {
var _ref = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee(container) {
var brightpearlService, client;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
brightpearlService = container.resolve("brightpearlService");
_context.prev = 1;
_context.next = 4;
return brightpearlService.getClient();
case 4:
client = _context.sent;
_context.next = 7;
return brightpearlService.verifyWebhooks();
case 7:
_context.next = 14;
break;
case 9:
_context.prev = 9;
_context.t0 = _context["catch"](1);
if (!(_context.t0.name === "not_allowed")) {
_context.next = 13;
break;
}
return _context.abrupt("return");
case 13:
throw _context.t0;
case 14:
case "end":
return _context.stop();
}
}
}, _callee, null, [[1, 9]]);
}));
return function webhookLoader(_x) {
return _ref.apply(this, arguments);
};
}();
var _default = webhookLoader;
exports["default"] = _default;

View File

@@ -1,21 +1,24 @@
const inventorySync = async (container) => {
const brightpearlService = container.resolve("brightpearlService")
const eventBus = container.resolve("eventBusService")
try {
const client = await brightpearlService.getClient()
const pattern = "43 4,10,14,20 * * *" // nice for tests "*/10 * * * * *"
eventBus.createCronJob(
"inventory-sync",
{},
pattern,
brightpearlService.syncInventory()
)
} catch (err) {
if (err.name === "not_allowed") {
return
const inventorySync = async (container, options) => {
if (!options.inventory_sync_cron) {
return
} else {
const brightpearlService = container.resolve("brightpearlService")
const eventBus = container.resolve("eventBusService")
try {
const client = await brightpearlService.getClient()
const pattern = options.inventory_sync_cron
eventBus.createCronJob(
"inventory-sync",
{},
pattern,
brightpearlService.syncInventory
)
} catch (err) {
if (err.name === "not_allowed") {
return
}
throw err
}
throw err
}
}

View File

@@ -0,0 +1,25 @@
const REFRESH_CRON = process.env.BP_REFRESH_CRON || "5 4 * * */6"
const refreshToken = async (container) => {
const logger = container.resolve("logger")
const oauthService = container.resolve("oauthService")
const eventBus = container.resolve("eventBusService")
try {
logger.info("registering refresh cron job BP")
eventBus.createCronJob("refresh-token-bp", {}, REFRESH_CRON, async () => {
const appData = await oauthService.retrieveByName("brightpearl")
const data = appData.data
if (data && data.refresh_token) {
return oauthService.refreshToken("brightpearl", data.refresh_token)
}
})
} catch (err) {
if (err.name === "not_allowed") {
return
}
throw err
}
}
export default refreshToken

View File

@@ -41,7 +41,11 @@ class BrightpearlClient {
"content-type": "application/x-www-form-urlencoded",
},
data: qs.stringify(params),
}).then(({ data }) => data)
})
.then(({ data }) => data)
.catch((err) => {
throw err
})
}
constructor(options, onRefreshToken) {

View File

@@ -1,426 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _axios = _interopRequireDefault(require("axios"));
var _axiosRateLimit = _interopRequireDefault(require("axios-rate-limit"));
var _querystring = _interopRequireDefault(require("querystring"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
function _instanceof(left, right) { if (right != null && typeof Symbol !== "undefined" && right[Symbol.hasInstance]) { return !!right[Symbol.hasInstance](left); } else { return left instanceof right; } }
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
function _classCallCheck(instance, Constructor) { if (!_instanceof(instance, Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
// Brightpearl allows 200 requests per minute
var RATE_LIMIT_REQUESTS = 200;
var RATE_LIMIT_INTERVAL = 60 * 1000;
var BrightpearlClient = /*#__PURE__*/function () {
_createClass(BrightpearlClient, null, [{
key: "createToken",
value: function createToken(account, data) {
var params = {
grant_type: "authorization_code",
code: data.code,
client_id: data.client_id,
client_secret: data.client_secret,
redirect_uri: data.redirect
};
return (0, _axios["default"])({
url: "https://ws-eu1.brightpearl.com/".concat(account, "/oauth/token"),
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded"
},
data: _querystring["default"].stringify(params)
}).then(function (_ref) {
var data = _ref.data;
return data;
});
}
}, {
key: "refreshToken",
value: function refreshToken(account, data) {
var params = {
grant_type: "refresh_token",
refresh_token: data.refresh_token,
client_id: data.client_id,
client_secret: data.client_secret
};
return (0, _axios["default"])({
url: "https://ws-eu1.brightpearl.com/".concat(account, "/oauth/token"),
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded"
},
data: _querystring["default"].stringify(params)
}).then(function (_ref2) {
var data = _ref2.data;
return data;
});
}
}]);
function BrightpearlClient(options, onRefreshToken) {
var _this = this;
_classCallCheck(this, BrightpearlClient);
_defineProperty(this, "buildWebhookEndpoints", function () {
return {
list: function list() {
return _this.client_.request({
url: "/integration-service/webhook",
method: "GET"
}).then(function (_ref3) {
var data = _ref3.data;
return data.response;
});
},
create: function create(data) {
return _this.client_.request({
url: "/integration-service/webhook",
method: "POST",
data: data
});
}
};
});
_defineProperty(this, "buildPaymentEndpoints", function () {
return {
create: function create(payment) {
return _this.client_.request({
url: "/accounting-service/customer-payment",
method: "POST",
data: payment
}).then(function (_ref4) {
var data = _ref4.data;
return data.response;
});
}
};
});
_defineProperty(this, "buildWarehouseEndpoints", function () {
return {
retrieveReservation: function retrieveReservation(orderId) {
return _this.client_.request({
url: "/warehouse-service/order/".concat(orderId, "/reservation"),
method: "GET"
}).then(function (_ref5) {
var data = _ref5.data;
return data.response;
});
},
retrieveGoodsOutNote: function retrieveGoodsOutNote(id) {
return _this.client_.request({
url: "/warehouse-service/order/*/goods-note/goods-out/".concat(id),
method: "GET"
}).then(function (_ref6) {
var data = _ref6.data;
return data.response && data.response[id];
});
},
createGoodsOutNote: function createGoodsOutNote(orderId, data) {
return _this.client_.request({
url: "/warehouse-service/order/".concat(orderId, "/goods-note/goods-out"),
method: "POST",
data: data
}).then(function (_ref7) {
var data = _ref7.data;
return data.response;
});
},
updateGoodsOutNote: function updateGoodsOutNote(noteId, update) {
return _this.client_.request({
url: "/warehouse-service/goods-note/goods-out/".concat(noteId),
method: "PUT",
data: update
});
},
registerGoodsOutEvent: function registerGoodsOutEvent(noteId, data) {
return _this.client_.request({
url: "/warehouse-service/goods-note/goods-out/".concat(noteId, "/event"),
method: "POST",
data: data
});
},
createReservation: function createReservation(order, warehouse) {
var id = order.id;
var data = order.rows.map(function (r) {
return {
productId: r.productId,
salesOrderRowId: r.id,
quantity: r.quantity
};
});
return _this.client_.request({
url: "/warehouse-service/order/".concat(id, "/reservation/warehouse/").concat(warehouse),
method: "POST",
data: {
products: data
}
}).then(function (_ref8) {
var data = _ref8.data;
return data.response;
});
}
};
});
_defineProperty(this, "buildOrderEndpoints", function () {
return {
retrieve: function retrieve(orderId) {
return _this.client_.request({
url: "/order-service/sales-order/".concat(orderId),
method: "GET"
}).then(function (_ref9) {
var data = _ref9.data;
return data.response.length && data.response[0];
})["catch"](function (err) {
return console.log(err);
});
},
create: function create(order) {
return _this.client_.request({
url: "/order-service/sales-order",
method: "POST",
data: order
}).then(function (_ref10) {
var data = _ref10.data;
return data.response;
});
},
createCredit: function createCredit(salesCredit) {
return _this.client_.request({
url: "/order-service/sales-credit",
method: "POST",
data: salesCredit
}).then(function (_ref11) {
var data = _ref11.data;
return data.response;
});
}
};
});
_defineProperty(this, "buildAddressEndpoints", function () {
return {
create: function create(address) {
return _this.client_.request({
url: "/contact-service/postal-address",
method: "POST",
data: address
}).then(function (_ref12) {
var data = _ref12.data;
return data.response;
});
}
};
});
_defineProperty(this, "buildProductEndpoints", function () {
return {
retrieveAvailability: function retrieveAvailability(productId) {
return _this.client_.request({
url: "/warehouse-service/product-availability/".concat(productId)
}).then(function (_ref13) {
var data = _ref13.data;
return data.response && data.response;
});
},
retrieve: function retrieve(productId) {
return _this.client_.request({
url: "/product-service/product/".concat(productId)
}).then(function (_ref14) {
var data = _ref14.data;
return data.response && data.response[0];
});
},
search: function search(_search) {
return _this.client_.request({
url: "/product-service/product-search?".concat(_search)
}).then(function (_ref15) {
var data = _ref15.data;
return {
products: _this.buildSearchResults_(data.response),
metadata: data.response.metaData
};
});
},
retrieveBySKU: function retrieveBySKU(sku) {
return _this.client_.request({
url: "/product-service/product-search?SKU=".concat(sku)
}).then(function (_ref16) {
var data = _ref16.data;
return _this.buildSearchResults_(data.response);
});
}
};
});
_defineProperty(this, "buildCustomerEndpoints", function () {
return {
retrieveByEmail: function retrieveByEmail(email) {
return _this.client_.request({
url: "/contact-service/contact-search?primaryEmail=".concat(email)
}).then(function (_ref17) {
var data = _ref17.data;
return _this.buildSearchResults_(data.response);
});
},
create: function create(customerData) {
return _this.client_.request({
url: "/contact-service/contact",
method: "POST",
data: customerData
}).then(function (_ref18) {
var data = _ref18.data;
return data.response;
});
}
};
});
this.client_ = (0, _axiosRateLimit["default"])(_axios["default"].create({
baseURL: "https://".concat(options.url, "/public-api/").concat(options.account),
headers: {
"brightpearl-app-ref": "medusa-dev",
"brightpearl-dev-ref": "sebrindom"
}
}), {
maxRequests: RATE_LIMIT_REQUESTS,
perMilliseconds: RATE_LIMIT_INTERVAL
});
this.authType_ = options.auth_type;
this.token_ = options.access_token;
this.webhooks = this.buildWebhookEndpoints();
this.payments = this.buildPaymentEndpoints();
this.warehouses = this.buildWarehouseEndpoints();
this.orders = this.buildOrderEndpoints();
this.addresses = this.buildAddressEndpoints();
this.customers = this.buildCustomerEndpoints();
this.products = this.buildProductEndpoints();
this.buildRefreshTokenInterceptor_(onRefreshToken);
}
_createClass(BrightpearlClient, [{
key: "updateAuth",
value: function updateAuth(data) {
if (data.auth_type) {
this.authType_ = data.auth_type;
}
if (data.access_token) {
this.token_ = data.access_token;
}
}
}, {
key: "buildRefreshTokenInterceptor_",
value: function buildRefreshTokenInterceptor_(onRefresh) {
var _this2 = this;
this.client_.interceptors.request.use(function (request) {
var authType = _this2.authType_;
var token = _this2.token_;
if (token) {
request.headers["Authorization"] = "Bearer ".concat(token);
}
return request;
});
this.client_.interceptors.response.use(undefined, /*#__PURE__*/function () {
var _ref19 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee(error) {
var response;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
response = error.response;
if (!response) {
_context.next = 13;
break;
}
if (!(response.status === 401 && error.config && !error.config.__isRetryRequest)) {
_context.next = 13;
break;
}
_context.prev = 3;
_context.next = 6;
return onRefresh(_this2);
case 6:
_context.next = 11;
break;
case 8:
_context.prev = 8;
_context.t0 = _context["catch"](3);
return _context.abrupt("return", Promise.reject(error));
case 11:
// retry the original request
error.config.__isRetryRequest = true;
return _context.abrupt("return", _this2.client_(error.config));
case 13:
return _context.abrupt("return", Promise.reject(error));
case 14:
case "end":
return _context.stop();
}
}
}, _callee, null, [[3, 8]]);
}));
return function (_x) {
return _ref19.apply(this, arguments);
};
}());
}
}, {
key: "buildSearchResults_",
value: function buildSearchResults_(response) {
var results = response.results,
metaData = response.metaData; // Map the column names to the columns
return results.map(function (resColumns) {
var object = {};
for (var i = 0; i < resColumns.length; i++) {
var fieldName = metaData.columns[i].name;
object[fieldName] = resColumns[i];
}
return object;
});
}
}]);
return BrightpearlClient;
}();
var _default = BrightpearlClient;
exports["default"] = _default;

View File

@@ -74,9 +74,11 @@ async function runLoaders(pluginDetails, container) {
try {
const module = require(loader).default
if (typeof module === "function") {
await module(container)
await module(container, pluginDetails.options)
}
} catch (err) {
const logger = container.resolve("logger")
logger.warn(`Running loader failed: ${err.message}`)
return Promise.resolve()
}
})

View File

@@ -17,12 +17,6 @@ class EventBusService {
/** @private {object} to handle cron jobs */
this.cronHandlers_ = {}
/** @private {BullQueue} used for cron jobs */
this.cronQueue_ = new Bull(
`cron-jobs:queue`,
config.projectConfig.redis_url
)
const opts = {
createClient: type => {
switch (type) {
@@ -36,6 +30,9 @@ class EventBusService {
},
}
/** @private {BullQueue} used for cron jobs */
this.cronQueue_ = new Bull(`cron-jobs:queue`, opts)
/** @private {BullQueue} */
this.queue_ = new Bull(`${this.constructor.name}:queue`, opts)
@@ -86,12 +83,15 @@ class EventBusService {
* @return {BullJob} - the job from our queue
*/
emit(eventName, data) {
return this.queue_.add({
eventName,
data,
}, {
removeOnComplete: true
})
return this.queue_.add(
{
eventName,
data,
},
{
removeOnComplete: true,
}
)
}
/**
@@ -140,9 +140,15 @@ class EventBusService {
/**
* Registers a cron job.
* @param {string} eventName - the name of the event
* @param {object} data - the data to be sent with the event
* @param {string} cron - the cron pattern
* @param {function} handler - the handler to call on each cron job
* @return void
*/
createCronJob(eventName, data, cron, handler) {
this.registerCronHandler(eventName, handler)
this.logger_.info(`Registering ${eventName}`)
this.registerCronHandler_(eventName, handler)
return this.cronQueue_.add(
{
eventName,