Adds integration to send sales orders to brightpearl

This commit is contained in:
Sebastian Rindom
2020-08-03 10:23:45 +02:00
parent 630bf3abc6
commit c18677d5f2
20 changed files with 931 additions and 10 deletions
@@ -0,0 +1,13 @@
{
"plugins": [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-instanceof",
"@babel/plugin-transform-classes"
],
"presets": ["@babel/preset-env"],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-runtime"]
}
}
}
@@ -0,0 +1,9 @@
{
"plugins": ["prettier"],
"extends": ["prettier"],
"rules": {
"prettier/prettier": "error",
"semi": "error",
"no-unused-expressions": "true"
}
}
@@ -0,0 +1,15 @@
/lib
node_modules
.DS_store
.env*
/*.js
!index.js
yarn.lock
/dist
/api
/services
/models
/subscribers
@@ -0,0 +1,9 @@
/lib
node_modules
.DS_store
.env*
/*.js
!index.js
yarn.lock
@@ -0,0 +1,7 @@
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}
@@ -0,0 +1 @@
// noop
@@ -0,0 +1,42 @@
{
"name": "medusa-plugin-brightpearl",
"version": "1.0.0",
"description": "Brightpearl plugin for Medusa Commerce",
"main": "index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/medusa-plugin-brightpearl"
},
"author": "Sebastian Rindom",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@babel/cli": "^7.7.5",
"@babel/core": "^7.7.5",
"@babel/node": "^7.7.4",
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-transform-classes": "^7.9.5",
"@babel/plugin-transform-instanceof": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.5",
"@babel/register": "^7.7.4",
"@babel/runtime": "^7.9.6",
"client-sessions": "^0.8.0",
"cross-env": "^5.2.1",
"eslint": "^6.8.0",
"jest": "^25.5.2",
"medusa-test-utils": "^0.3.0"
},
"scripts": {
"build": "babel src -d dist",
"prepare": "cross-env NODE_ENV=production npm run build",
"watch": "babel -w src --out-dir . --ignore **/__tests__",
"test": "jest"
},
"dependencies": {
"axios": "^0.19.2",
"express": "^4.17.1",
"medusa-core-utils": "^0.3.0",
"medusa-interfaces": "^0.3.0"
}
}
@@ -0,0 +1,260 @@
import { BaseService } from "medusa-interfaces"
import Brightpearl from "../utils/brightpearl"
class BrightpearlService extends BaseService {
constructor({ totalsService, regionService, orderService, discountService }, options) {
super()
this.options = options
this.regionService_ = regionService
this.orderService_ = orderService
this.totalsService_ = totalsService
this.discountService_ = discountService
this.brightpearl_ = new Brightpearl({
account: options.account,
datacenter: options.datacenter,
app_ref: options.app_ref,
token: options.token
})
}
async createGoodsOutNote(fromOrder, shipment) {
const id = fromOrder.metadata && fromOrder.metadata.brightpearl_sales_order_id
if (!id) {
return
}
const order = await this.brightpearl_.orders.retrieve(id)
const productRows = shipment.item_ids.map(id => {
const row = order.rows.find(({ externalRef }) => externalRef === id)
return {
productId: row.productId,
salesOrderRowId: row.id,
quantity: row.quantity
}
})
const goodsOut = {
warehouses: [
{
releaseDate: new Date(),
warehouseId: this.options.warehouse,
transfer: false,
products: productRows,
}
],
priority: false,
}
return this.brightpearl_.warehouses.createGoodsOutNote(id, goodsOut)
}
registerGoodsOutShipped(noteId, shipment) {
return this.brightpearl_.warehouses.registerGoodsOutEvent(noteId, {
events: [
{
eventCode: "SHW",
occured: new Date(),
eventOwnerId: this.options.event_owner,
}
]
})
}
registerGoodsOutTrackingNumber(noteId, shipment) {
return this.brightpearl_.warehouses.updateGoodsOutNote(noteId, {
priority: false,
shipping: {
reference: shipment.tracking_number,
}
})
}
async createSalesOrder(fromOrder) {
let customer = await this.retrieveCustomerByEmail(fromOrder.email)
// All sales orders must have a customer
if (!customer) {
customer = await this.createCustomer(fromOrder)
}
const { shipping_address } = fromOrder
const order = {
currency: {
code: fromOrder.currency_code
},
externalRef: fromOrder._id,
customer: {
id: customer.contactId,
address: {
addressFullName: `${shipping_address.first_name} ${shipping_address.last_name}`,
addressLine1: shipping_address.address_1,
addressLine2: shipping_address.address_2,
postalCode: shipping_address.postal_code,
countryIsoCode: shipping_address.country_code,
telephone: shipping_address.phone,
email: fromOrder.email,
}
},
delivery: {
shippingMethodId: 0,
address: {
addressFullName: `${shipping_address.first_name} ${shipping_address.last_name}`,
addressLine1: shipping_address.address_1,
addressLine2: shipping_address.address_2,
postalCode: shipping_address.postal_code,
countryIsoCode: shipping_address.country_code,
telephone: shipping_address.phone,
email: fromOrder.email,
}
},
rows: await this.getBrightpearlRows(fromOrder)
}
return this.brightpearl_.orders.create(order)
.then(async salesOrderId => {
const order = await this.brightpearl_.orders.retrieve(salesOrderId)
const resResult = await this.brightpearl_.warehouses.createReservation(order, this.options.warehouse)
return salesOrderId
})
.then(async salesOrderId => {
const paymentMethod = fromOrder.payment_method
const paymentType = "AUTH"
const payment = {
transactionRef: `${paymentMethod._id}.${paymentType}`, // Brightpearl cannot accept an auth and capture with same ref
transactionCode: fromOrder._id,
paymentMethodCode: "1220",
orderId: salesOrderId,
currencyIsoCode: fromOrder.currency_code,
paymentDate: new Date(),
paymentType,
}
// Only if authorization type
if (paymentType === "AUTH") {
const today = new Date()
const authExpire = today.setDate(today.getDate() + 7)
payment.amountAuthorized = await this.totalsService_.getTotal(fromOrder)
payment.authorizationExpiry = new Date(authExpire)
} else {
// For captured
}
await this.brightpearl_.payments.create(payment)
return salesOrderId
})
.then((salesOrderId) => {
return this.orderService_.setMetadata(fromOrder._id, "brightpearl_sales_order_id", salesOrderId)
})
}
async createCapturedPayment(fromOrder) {
const soId = fromOrder.metadata && fromOrder.metadata.brightpearl_sales_order_id
if (!soId) {
return
}
const paymentType = "CAPTURE"
const paymentMethod = fromOrder.payment_method
const payment = {
transactionRef: `${paymentMethod._id}.${paymentType}`, // Brightpearl cannot accept an auth and capture with same ref
transactionCode: fromOrder._id,
paymentMethodCode: "1220",
orderId: soId,
paymentDate: new Date(),
currencyIsoCode: fromOrder.currency_code,
amountPaid: await this.totalsService_.getTotal(fromOrder),
paymentType,
}
await this.brightpearl_.payments.create(payment)
}
async getBrightpearlRows(fromOrder) {
const region = await this.regionService_.retrieve(fromOrder.region_id)
const discount = fromOrder.discounts.find(({ discount_rule }) => discount_rule.type !== "free_shipping")
let lineDiscounts = []
if (discount) {
lineDiscounts = this.discountService_.getLineDiscounts(fromOrder, discount)
}
const lines = await Promise.all(fromOrder.items.map(async item => {
const bpProduct = await this.retrieveProductBySKU(item.content.variant.sku)
const discount = lineDiscounts.find(l => l.item._id.equals(item._id)) || { amount: 0 }
const row = {}
if (bpProduct) {
row.productId = bpProduct.productId
} else {
row.name = item.title
}
row.net = item.content.unit_price * item.quantity - discount.amount
row.tax = row.net * fromOrder.tax_rate
row.quantity = item.quantity
row.taxCode = region.tax_code
row.externalRef = item._id
row.nominalCode = this.options.sales_account_code || "4000"
return row
}))
const shippingTotal = this.totalsService_.getShippingTotal(fromOrder)
const shippingMethods = fromOrder.shipping_methods
if (shippingMethods.length > 0) {
lines.push({
name: `Shipping: ${shippingMethods.map(m => m.name).join(" + ")}`,
quantity: 1,
net: shippingTotal,
tax: shippingTotal * fromOrder.tax_rate,
taxCode: region.tax_code,
nominalCode: this.options.shipping_account_code || "4040",
})
}
return lines
}
retrieveCustomerByEmail(email) {
return this.brightpearl_.customers.retrieveByEmail(email).then(customers => {
if (!customers.length) {
return null
}
return customers.find(c => c.primaryEmail === email)
})
}
retrieveProductBySKU(sku) {
return this.brightpearl_.products.retrieveBySKU(sku).then(products => {
if (!products.length) {
return null
}
return products[0]
})
}
async createCustomer(fromOrder) {
const address = await this.brightpearl_.addresses.create({
addressLine1: fromOrder.shipping_address.address_1,
addressLine2: fromOrder.shipping_address.address_2,
postalCode: fromOrder.shipping_address.postal_code,
countryIsoCode: fromOrder.shipping_address.country_code,
})
const customer = await this.brightpearl_.customers.create({
firstName: fromOrder.shipping_address.first_name,
lastName: fromOrder.shipping_address.last_name,
postAddressIds: {
DEF: address,
BIL: address,
DEL: address,
}
})
return { contactId: customer }
}
}
export default BrightpearlService
@@ -0,0 +1,31 @@
class OrderSubscriber {
constructor({ eventBusService, orderService, brightpearlService }) {
this.orderService_ = orderService
this.brightpearlService_ = brightpearlService
eventBusService.subscribe("order.placed", this.sendToBrightpearl)
eventBusService.subscribe("order.payment_captured", this.registerCapturedPayment)
eventBusService.subscribe("order.shipment_created", this.registerShipment)
}
sendToBrightpearl = order => {
return this.brightpearlService_.createSalesOrder(order)
}
registerCapturedPayment = order => {
return this.brightpearlService_.createCapturedPayment(order)
}
registerShipment = async (data) => {
const { order_id, shipment } = data
const order = await this.orderService_.retrieve(order_id)
const notes = await this.brightpearlService_.createGoodsOutNote(order, shipment)
if (notes.length) {
const noteId = notes[0]
await this.brightpearlService_.registerGoodsOutTrackingNumber(noteId, shipment)
await this.brightpearlService_.registerGoodsOutShipped(noteId, shipment)
}
}
}
export default OrderSubscriber
@@ -0,0 +1,166 @@
import axios from "axios"
class BrightpearlClient {
constructor(options) {
this.client_ = axios.create({
baseURL: `https://${options.datacenter}.brightpearl.com/public-api/${options.account}`,
headers: {
'brightpearl-app-ref': options.app_ref,
'brightpearl-account-token': options.token
},
})
this.payments = this.buildPaymentEndpoints()
this.warehouses = this.buildWarehouseEndpoints()
this.orders = this.buildOrderEndpoints()
this.addresses = this.buildAddressEndpoints()
this.customers = this.buildCustomerEndpoints()
this.products = this.buildProductEndpoints()
}
buildSearchResults_(response) {
const { results, metaData } = response
// Map the column names to the columns
return results.map(resColumns => {
const object = {}
for (let i = 0; i < resColumns.length; i++) {
const fieldName = metaData.columns[i].name
object[fieldName] = resColumns[i]
}
return object
})
}
buildPaymentEndpoints = () => {
return {
create: (payment) => {
return this.client_.request({
url: `/accounting-service/customer-payment`,
method: "POST",
data: payment
})
.then(({ data }) => data.response)
}
}
}
buildWarehouseEndpoints = () => {
return {
retrieveReservation: (orderId) => {
return this.client_.request({
url: `/warehouse-service/order/${orderId}/reservation`,
method: "GET",
})
.then(({ data }) => data.response)
},
createGoodsOutNote: (orderId, data) => {
return this.client_.request({
url: `/warehouse-service/order/${orderId}/goods-note/goods-out`,
method: "POST",
data,
}).then(({ data }) => data.response)
},
updateGoodsOutNote: (noteId, update) => {
return this.client_.request({
url: `/warehouse-service/goods-note/goods-out/${noteId}`,
method: "PUT",
data: update
})
},
registerGoodsOutEvent: (noteId, data) => {
return this.client_.request({
url: `/warehouse-service/goods-note/goods-out/${noteId}/event`,
method: "POST",
data
})
},
createReservation: (order, warehouse) => {
const id = order.id
const data = order.rows.map(r => ({
productId: r.productId,
salesOrderRowId: r.id,
quantity: r.quantity
}))
return this.client_.request({
url: `/warehouse-service/order/${id}/reservation/warehouse/${warehouse}`,
method: "POST",
data: {
products: data
}
}).then(({ data }) => data.response)
}
}
}
buildOrderEndpoints = () => {
return {
retrieve: (orderId) => {
return this.client_.request({
url: `/order-service/sales-order/${orderId}`,
method: "GET",
})
.then(({ data }) => data.response.length && data.response[0])
.catch(err => console.log(err))
},
create: (order) => {
return this.client_.request({
url: `/order-service/sales-order`,
method: "POST",
data: order
})
.then(({ data }) => data.response)
},
}
}
buildAddressEndpoints = () => {
return {
create: (address) => {
return this.client_.request({
url: `/contact-service/postal-address`,
method: "POST",
data: address
})
.then(({ data }) => data.response)
}
}
}
buildProductEndpoints = () => {
return {
retrieveBySKU: (sku) => {
return this.client_.request({
url: `/product-service/product-search?SKU=${sku}`,
})
.then(({ data }) => {
return this.buildSearchResults_(data.response)
})
}
}
}
buildCustomerEndpoints = () => {
return {
retrieveByEmail: (email) => {
return this.client_.request({
url: `/contact-service/contact-search?primaryEmail=${email}`,
})
.then(({data }) => {
return this.buildSearchResults_(data.response)
})
},
create: (customerData) => {
return this.client_.request({
url: `/contact-service/contact`,
method: "POST",
data: customerData
})
.then(({ data }) => data.response)
}
}
}
}
export default BrightpearlClient
@@ -0,0 +1,216 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _axios = _interopRequireDefault(require("axios"));
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 _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; }
var BrightpearlClient = /*#__PURE__*/function () {
function BrightpearlClient(options) {
var _this = this;
_classCallCheck(this, BrightpearlClient);
_defineProperty(this, "buildPaymentEndpoints", function () {
return {
create: function create(payment) {
return _this.client_.request({
url: "/accounting-service/customer-payment",
method: "POST",
data: payment
}).then(function (_ref) {
var data = _ref.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 (_ref2) {
var data = _ref2.data;
return data.response;
});
},
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 (_ref3) {
var data = _ref3.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 (_ref4) {
var data = _ref4.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 (_ref5) {
var data = _ref5.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 (_ref6) {
var data = _ref6.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 (_ref7) {
var data = _ref7.data;
return data.response;
});
}
};
});
_defineProperty(this, "buildProductEndpoints", function () {
return {
retrieveBySKU: function retrieveBySKU(sku) {
return _this.client_.request({
url: "/product-service/product-search?SKU=".concat(sku)
}).then(function (_ref8) {
var data = _ref8.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 (_ref9) {
var data = _ref9.data;
return _this.buildSearchResults_(data.response);
});
},
create: function create(customerData) {
return _this.client_.request({
url: "/contact-service/contact",
method: "POST",
data: customerData
}).then(function (_ref10) {
var data = _ref10.data;
return data.response;
});
}
};
});
this.client_ = _axios["default"].create({
baseURL: "https://".concat(options.datacenter, ".brightpearl.com/public-api/").concat(options.account),
headers: {
'brightpearl-app-ref': options.app_ref,
'brightpearl-account-token': options.token
}
});
this.payments = this.buildPaymentEndpoints();
this.warehouses = this.buildWarehouseEndpoints();
this.orders = this.buildOrderEndpoints();
this.addresses = this.buildAddressEndpoints();
this.customers = this.buildCustomerEndpoints();
this.products = this.buildProductEndpoints();
}
_createClass(BrightpearlClient, [{
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;