From c18677d5f2b2605635613cab0e03bf1870485a11 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Mon, 3 Aug 2020 10:23:45 +0200 Subject: [PATCH] Adds integration to send sales orders to brightpearl --- .../src/services/stripe-provider.js | 6 + packages/medusa-plugin-brightpearl/.babelrc | 13 + packages/medusa-plugin-brightpearl/.eslintrc | 9 + packages/medusa-plugin-brightpearl/.gitignore | 15 + packages/medusa-plugin-brightpearl/.npmignore | 9 + .../medusa-plugin-brightpearl/.prettierrc | 7 + packages/medusa-plugin-brightpearl/index.js | 1 + .../medusa-plugin-brightpearl/package.json | 42 +++ .../src/services/brightpearl.js | 260 ++++++++++++++++++ .../src/subscribers/order.js | 31 +++ .../src/utils/brightpearl.js | 166 +++++++++++ .../utils/brightpearl.js | 216 +++++++++++++++ .../src/services/segment.js | 1 - packages/medusa/src/api/index.js | 33 +++ packages/medusa/src/models/order.js | 3 + packages/medusa/src/models/region.js | 1 + .../medusa/src/models/schemas/shipment.js | 7 + packages/medusa/src/services/order.js | 76 ++++- packages/medusa/src/services/totals.js | 39 +++ packages/medusa/src/subscribers/order.js | 6 +- 20 files changed, 931 insertions(+), 10 deletions(-) create mode 100644 packages/medusa-plugin-brightpearl/.babelrc create mode 100644 packages/medusa-plugin-brightpearl/.eslintrc create mode 100644 packages/medusa-plugin-brightpearl/.gitignore create mode 100644 packages/medusa-plugin-brightpearl/.npmignore create mode 100644 packages/medusa-plugin-brightpearl/.prettierrc create mode 100644 packages/medusa-plugin-brightpearl/index.js create mode 100644 packages/medusa-plugin-brightpearl/package.json create mode 100644 packages/medusa-plugin-brightpearl/src/services/brightpearl.js create mode 100644 packages/medusa-plugin-brightpearl/src/subscribers/order.js create mode 100644 packages/medusa-plugin-brightpearl/src/utils/brightpearl.js create mode 100644 packages/medusa-plugin-brightpearl/utils/brightpearl.js create mode 100644 packages/medusa/src/models/schemas/shipment.js diff --git a/packages/medusa-payment-stripe/src/services/stripe-provider.js b/packages/medusa-payment-stripe/src/services/stripe-provider.js index 183b34c8af..2070a5c885 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-provider.js +++ b/packages/medusa-payment-stripe/src/services/stripe-provider.js @@ -162,6 +162,12 @@ class StripeProviderService extends PaymentService { try { const { id } = data return this.stripe_.paymentIntents.cancel(id) + .catch(err => { + if (err.statusCode === 400) { + return + } + throw err + }) } catch (error) { throw error } diff --git a/packages/medusa-plugin-brightpearl/.babelrc b/packages/medusa-plugin-brightpearl/.babelrc new file mode 100644 index 0000000000..4d2dfe8f09 --- /dev/null +++ b/packages/medusa-plugin-brightpearl/.babelrc @@ -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"] + } + } +} diff --git a/packages/medusa-plugin-brightpearl/.eslintrc b/packages/medusa-plugin-brightpearl/.eslintrc new file mode 100644 index 0000000000..2a889697f0 --- /dev/null +++ b/packages/medusa-plugin-brightpearl/.eslintrc @@ -0,0 +1,9 @@ +{ + "plugins": ["prettier"], + "extends": ["prettier"], + "rules": { + "prettier/prettier": "error", + "semi": "error", + "no-unused-expressions": "true" + } +} diff --git a/packages/medusa-plugin-brightpearl/.gitignore b/packages/medusa-plugin-brightpearl/.gitignore new file mode 100644 index 0000000000..a28122e219 --- /dev/null +++ b/packages/medusa-plugin-brightpearl/.gitignore @@ -0,0 +1,15 @@ +/lib +node_modules +.DS_store +.env* +/*.js +!index.js +yarn.lock + +/dist + +/api +/services +/models +/subscribers + diff --git a/packages/medusa-plugin-brightpearl/.npmignore b/packages/medusa-plugin-brightpearl/.npmignore new file mode 100644 index 0000000000..486581be18 --- /dev/null +++ b/packages/medusa-plugin-brightpearl/.npmignore @@ -0,0 +1,9 @@ +/lib +node_modules +.DS_store +.env* +/*.js +!index.js +yarn.lock + + diff --git a/packages/medusa-plugin-brightpearl/.prettierrc b/packages/medusa-plugin-brightpearl/.prettierrc new file mode 100644 index 0000000000..70175ce150 --- /dev/null +++ b/packages/medusa-plugin-brightpearl/.prettierrc @@ -0,0 +1,7 @@ +{ + "endOfLine": "lf", + "semi": false, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5" +} \ No newline at end of file diff --git a/packages/medusa-plugin-brightpearl/index.js b/packages/medusa-plugin-brightpearl/index.js new file mode 100644 index 0000000000..172f1ae6a4 --- /dev/null +++ b/packages/medusa-plugin-brightpearl/index.js @@ -0,0 +1 @@ +// noop diff --git a/packages/medusa-plugin-brightpearl/package.json b/packages/medusa-plugin-brightpearl/package.json new file mode 100644 index 0000000000..3add66aa9a --- /dev/null +++ b/packages/medusa-plugin-brightpearl/package.json @@ -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" + } +} diff --git a/packages/medusa-plugin-brightpearl/src/services/brightpearl.js b/packages/medusa-plugin-brightpearl/src/services/brightpearl.js new file mode 100644 index 0000000000..ade9a2b93a --- /dev/null +++ b/packages/medusa-plugin-brightpearl/src/services/brightpearl.js @@ -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 diff --git a/packages/medusa-plugin-brightpearl/src/subscribers/order.js b/packages/medusa-plugin-brightpearl/src/subscribers/order.js new file mode 100644 index 0000000000..4b9bd7856b --- /dev/null +++ b/packages/medusa-plugin-brightpearl/src/subscribers/order.js @@ -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 diff --git a/packages/medusa-plugin-brightpearl/src/utils/brightpearl.js b/packages/medusa-plugin-brightpearl/src/utils/brightpearl.js new file mode 100644 index 0000000000..effdcbe728 --- /dev/null +++ b/packages/medusa-plugin-brightpearl/src/utils/brightpearl.js @@ -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 diff --git a/packages/medusa-plugin-brightpearl/utils/brightpearl.js b/packages/medusa-plugin-brightpearl/utils/brightpearl.js new file mode 100644 index 0000000000..279209cf18 --- /dev/null +++ b/packages/medusa-plugin-brightpearl/utils/brightpearl.js @@ -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; \ No newline at end of file diff --git a/packages/medusa-plugin-segment/src/services/segment.js b/packages/medusa-plugin-segment/src/services/segment.js index 1cbb9bbd16..82994fb06b 100644 --- a/packages/medusa-plugin-segment/src/services/segment.js +++ b/packages/medusa-plugin-segment/src/services/segment.js @@ -50,7 +50,6 @@ class SegmentService extends BaseService { async buildOrder(order) { - console.log("build", order) const subtotal = await this.totalsService_.getSubtotal(order) const total = await this.totalsService_.getTotal(order) const tax = await this.totalsService_.getTaxTotal(order) diff --git a/packages/medusa/src/api/index.js b/packages/medusa/src/api/index.js index 4d0b4b9002..b710f21d44 100644 --- a/packages/medusa/src/api/index.js +++ b/packages/medusa/src/api/index.js @@ -7,6 +7,39 @@ import errorHandler from "./middlewares/error-handler" export default (container, config) => { const app = Router() + app.post("/create-shipment/:order_id", async (req, res) => { + const orderService = req.scope.resolve("orderService") + const eventBus = req.scope.resolve("eventBusService") + const order = await orderService.retrieve(req.params.order_id) + + await orderService.createShipment(order._id, { + item_ids: order.items.map(({ _id }) => `${_id}`), + tracking_number: "1234", + }) + + res.sendStatus(200) + }) + + app.post("/run-hook/:order_id/capture", async (req, res) => { + const orderService = req.scope.resolve("orderService") + const eventBus = req.scope.resolve("eventBusService") + const order = await orderService.retrieve(req.params.order_id) + + eventBus.emit("order.payment_captured", order) + + res.sendStatus(200) + }) + + app.post("/run-hook/:order_id", async (req, res) => { + const orderService = req.scope.resolve("orderService") + const eventBus = req.scope.resolve("eventBusService") + const order = await orderService.retrieve(req.params.order_id) + + eventBus.emit("order.placed", order) + + res.sendStatus(200) + }) + admin(app, container, config) store(app, container, config) diff --git a/packages/medusa/src/models/order.js b/packages/medusa/src/models/order.js index f0db2b003c..41190fcd10 100644 --- a/packages/medusa/src/models/order.js +++ b/packages/medusa/src/models/order.js @@ -6,6 +6,7 @@ import PaymentMethodSchema from "./schemas/payment-method" import ShippingMethodSchema from "./schemas/shipping-method" import AddressSchema from "./schemas/address" import DiscountSchema from "./schemas/discount" +import ShipmentSchema from "./schemas/shipment" class OrderModel extends BaseModel { static modelName = "Order" @@ -23,6 +24,8 @@ class OrderModel extends BaseModel { shipping_address: { type: AddressSchema, required: true }, items: { type: [LineItemSchema], required: true }, currency_code: { type: String, required: true }, + tax_rate: { type: Number, required: true }, + shipments: { type: [ShipmentSchema], default: [] }, region_id: { type: String, required: true }, discounts: { type: [DiscountSchema], default: [] }, customer_id: { type: String }, diff --git a/packages/medusa/src/models/region.js b/packages/medusa/src/models/region.js index aa13126369..97ee74a03e 100644 --- a/packages/medusa/src/models/region.js +++ b/packages/medusa/src/models/region.js @@ -7,6 +7,7 @@ class RegionModel extends BaseModel { name: { type: String, required: true }, currency_code: { type: String, required: true }, tax_rate: { type: Number, required: true, default: 0 }, + tax_code: { type: String }, countries: { type: [String], default: [] }, payment_providers: { type: [String], default: [] }, fulfillment_providers: { type: [String], default: [] }, diff --git a/packages/medusa/src/models/schemas/shipment.js b/packages/medusa/src/models/schemas/shipment.js new file mode 100644 index 0000000000..e21b5dc924 --- /dev/null +++ b/packages/medusa/src/models/schemas/shipment.js @@ -0,0 +1,7 @@ +import mongoose from "mongoose" + +export default new mongoose.Schema({ + item_ids: { type: [String], required: true }, + tracking_number: { type: String, default: "" }, + metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, +}) diff --git a/packages/medusa/src/services/order.js b/packages/medusa/src/services/order.js index d1f385e8e7..c261b0cb44 100644 --- a/packages/medusa/src/services/order.js +++ b/packages/medusa/src/services/order.js @@ -5,6 +5,8 @@ import { BaseService } from "medusa-interfaces" class OrderService extends BaseService { static Events = { GIFT_CARD_CREATED: "order.gift_card_created", + PAYMENT_CAPTURED: "order.payment_captured", + SHIPMENT_CREATED: "order.shipment_created", PLACED: "order.placed", UPDATED: "order.updated", CANCELLED: "order.cancelled", @@ -352,6 +354,7 @@ class OrderService extends BaseService { email: cart.email, customer_id: cart.customer_id, cart_id: cart._id, + tax_rate: region.tax_rate, currency_code: region.currency_code, } @@ -366,6 +369,58 @@ class OrderService extends BaseService { .then(() => this.orderModel_.findOne({ cart_id: cart._id })) } + /** + * Adds a shipment to the order to indicate that an order has left the warehouse + */ + async createShipment(orderId, shipment) { + const order = await this.retrieve(orderId) + + console.log(order) + if (order.fulfillment_status === "shipped") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Order has already been shipped" + ) + } + + const shipmentSchema = Validator.object({ + item_ids: Validator.array() + .items(Validator.string()) + .required(), + tracking_number: Validator.string().required(), + }) + + const { value, error } = shipmentSchema.validate(shipment) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Shipment not valid: ${error}` + ) + } + + const existing = order.shipments || [] + const shipments = [...existing, value] + const allCovered = order.items.every( + i => !!shipments.find(s => s.item_ids.includes(`${i._id}`)) + ) + + const update = { + $push: { shipments: value }, + $set: { + fulfillment_status: allCovered ? "shipped" : "partially_shipped", + }, + } + + // Add the shipment to the order + return this.orderModel_.updateOne({ _id: orderId }, update).then(result => { + this.eventBus_.emit(OrderService.Events.SHIPMENT_CREATED, { + order_id: orderId, + shipment, + }) + return result + }) + } + /** * Creates an order * @param {object} order - the order to create @@ -535,14 +590,19 @@ class OrderService extends BaseService { await paymentProvider.capturePayment(data) - return this.orderModel_.updateOne( - { - _id: orderId, - }, - { - $set: updateFields, - } - ) + return this.orderModel_ + .updateOne( + { + _id: orderId, + }, + { + $set: updateFields, + } + ) + .then(result => { + this.eventBus_.emit(OrderService.Events.PAYMENT_CAPTURED, result) + return result + }) } /** diff --git a/packages/medusa/src/services/totals.js b/packages/medusa/src/services/totals.js index 09289df137..40ee9d98ce 100644 --- a/packages/medusa/src/services/totals.js +++ b/packages/medusa/src/services/totals.js @@ -252,6 +252,45 @@ class TotalsService extends BaseService { return discounts } + async getLineDiscounts(cart, discount) { + const subtotal = this.getSubtotal(cart) + const { type, allocation, value } = discount.discount_rule + if (allocation === "total") { + let percentage = 0 + if (type === "percentage") { + percentage = value / 100 + } else if (type === "fixed") { + percentage = value / subtotal + } + + return cart.items.map(item => { + const lineTotal = item.content.unit_price * item.quantity + + return { + item, + amount: lineTotal * percentage, + } + }) + } else if (allocation === "item") { + const allocationDiscounts = this.getAllocationItemDiscounts( + discount, + cart, + type + ) + return cart.items.map(item => { + const discounted = allocationDiscounts.find(a => + a.lineItem._id.equals(item._id) + ) + return { + item, + amount: !!discounted ? discounted.amount : 0, + } + }) + } + + return cart.items.map(i => ({ item: i, amount: 0 })) + } + /** * Calculates the total discount amount for each of the different supported * discount types. If discounts aren't present or invalid returns 0. diff --git a/packages/medusa/src/subscribers/order.js b/packages/medusa/src/subscribers/order.js index 81f8f708c6..3f63381c65 100644 --- a/packages/medusa/src/subscribers/order.js +++ b/packages/medusa/src/subscribers/order.js @@ -39,7 +39,11 @@ class OrderSubscriber { }) this.eventBus_.subscribe("order.placed", async order => { - await this.cartService_.delete(order.cart_id) + await this.cartService_.delete(order.cart_id).catch(err => { + if (err.type !== "not_found") { + throw err + } + }) }) this.eventBus_.subscribe("order.placed", this.handleDiscounts)