From 893a7f69afea67e854a67fc3b92c8a10c9c1b75c Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Mon, 5 Oct 2020 08:44:12 +0200 Subject: [PATCH] feat: webshipper (#118) * register fulfillment providers as singletons * fix: adds subscribers/api endpoints and webhook handlers * Adds readme * Allow document attachments to orders: * chore: gitignore utils * chore: rm compiled files --- .../medusa-fulfillment-webshipper/.babelrc | 13 + .../medusa-fulfillment-webshipper/.eslintrc | 9 + .../medusa-fulfillment-webshipper/.gitignore | 14 + .../medusa-fulfillment-webshipper/.npmignore | 10 + .../medusa-fulfillment-webshipper/.prettierrc | 7 + .../medusa-fulfillment-webshipper/README.md | 15 ++ .../medusa-fulfillment-webshipper/index.js | 1 + .../package.json | 41 +++ .../src/api/index.js | 73 ++++++ .../src/services/webshipper-fulfillment.js | 239 ++++++++++++++++++ .../src/subscribers/webshipper.js | 15 ++ .../src/utils/webshipper.js | 97 +++++++ .../src/fulfillment-service.js | 29 ++- packages/medusa/src/loaders/plugins.js | 23 +- 14 files changed, 577 insertions(+), 9 deletions(-) create mode 100644 packages/medusa-fulfillment-webshipper/.babelrc create mode 100644 packages/medusa-fulfillment-webshipper/.eslintrc create mode 100644 packages/medusa-fulfillment-webshipper/.gitignore create mode 100644 packages/medusa-fulfillment-webshipper/.npmignore create mode 100644 packages/medusa-fulfillment-webshipper/.prettierrc create mode 100644 packages/medusa-fulfillment-webshipper/README.md create mode 100644 packages/medusa-fulfillment-webshipper/index.js create mode 100644 packages/medusa-fulfillment-webshipper/package.json create mode 100644 packages/medusa-fulfillment-webshipper/src/api/index.js create mode 100644 packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js create mode 100644 packages/medusa-fulfillment-webshipper/src/subscribers/webshipper.js create mode 100644 packages/medusa-fulfillment-webshipper/src/utils/webshipper.js diff --git a/packages/medusa-fulfillment-webshipper/.babelrc b/packages/medusa-fulfillment-webshipper/.babelrc new file mode 100644 index 0000000000..1d150fdfd9 --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/.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"] + } + } +} \ No newline at end of file diff --git a/packages/medusa-fulfillment-webshipper/.eslintrc b/packages/medusa-fulfillment-webshipper/.eslintrc new file mode 100644 index 0000000000..797b6ef5bf --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/.eslintrc @@ -0,0 +1,9 @@ +{ + "plugins": ["prettier"], + "extends": ["prettier"], + "rules": { + "prettier/prettier": "error", + "semi": "error", + "no-unused-expressions": "true" + } +} \ No newline at end of file diff --git a/packages/medusa-fulfillment-webshipper/.gitignore b/packages/medusa-fulfillment-webshipper/.gitignore new file mode 100644 index 0000000000..cabefae64b --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/.gitignore @@ -0,0 +1,14 @@ +/lib +node_modules +.DS_store +.env* +/*.js +!index.js +yarn.lock + +/api +/services +/models +/subscribers +/utils + diff --git a/packages/medusa-fulfillment-webshipper/.npmignore b/packages/medusa-fulfillment-webshipper/.npmignore new file mode 100644 index 0000000000..2c51bc25d4 --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/.npmignore @@ -0,0 +1,10 @@ +/lib +node_modules +.DS_store +.env* +/*.js +!index.js +yarn.lock +/src + + diff --git a/packages/medusa-fulfillment-webshipper/.prettierrc b/packages/medusa-fulfillment-webshipper/.prettierrc new file mode 100644 index 0000000000..70175ce150 --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/.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-fulfillment-webshipper/README.md b/packages/medusa-fulfillment-webshipper/README.md new file mode 100644 index 0000000000..8055be0b42 --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/README.md @@ -0,0 +1,15 @@ +# medusa-fulfillment-webshipper + +Adds Webshipper as a fulfilment provider in Medusa Commerce. + +On each new fulfillment an order is created in Webshipper. The plugin listens for shipment events and updated the shipment accordingly. +A webhook listener is exposed at `/webshipper/shipments` to listen for shipment creations. You must create this webhook in Webshipper to have Medusa listen for shipment events. + +## Options + +``` + account: [your webshipper account] (required) + api_token: [a webshipper api token] (required) + order_channel_id: [the channel id to register orders on] (required) + webhook_secret: [the webhook secret used to listen for shipments] (required) +``` diff --git a/packages/medusa-fulfillment-webshipper/index.js b/packages/medusa-fulfillment-webshipper/index.js new file mode 100644 index 0000000000..172f1ae6a4 --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/index.js @@ -0,0 +1 @@ +// noop diff --git a/packages/medusa-fulfillment-webshipper/package.json b/packages/medusa-fulfillment-webshipper/package.json new file mode 100644 index 0000000000..9d78b0f9d8 --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/package.json @@ -0,0 +1,41 @@ +{ + "name": "medusa-fulfillment-webshipper", + "version": "1.0.0", + "description": "Webshipper Fulfillment provider for Medusa", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/medusa-fulfillment-webshipper" + }, + "author": "Sebastian Rindom", + "license": "MIT", + "devDependencies": { + "@babel/cli": "^7.7.5", + "@babel/core": "^7.7.5", + "@babel/plugin-proposal-class-properties": "^7.7.4", + "@babel/plugin-transform-runtime": "^7.7.6", + "@babel/preset-env": "^7.7.5", + "@babel/runtime": "^7.9.6", + "client-sessions": "^0.8.0", + "cross-env": "^5.2.1", + "eslint": "^6.8.0", + "jest": "^25.5.2" + }, + "scripts": { + "build": "babel src -d .", + "prepare": "cross-env NODE_ENV=production npm run build", + "watch": "babel -w src --out-dir . --ignore **/__tests__" + }, + "peerDependencies": { + "medusa-interfaces": "1.x" + }, + "dependencies": { + "axios": "^0.20.0", + "body-parser": "^1.19.0", + "cors": "^2.8.5", + "express": "^4.17.1", + "medusa-core-utils": "^1.0.10" + }, + "gitHead": "3cc7cbe5124cbcbb75f6e1435db4dcfaa2a60408" +} diff --git a/packages/medusa-fulfillment-webshipper/src/api/index.js b/packages/medusa-fulfillment-webshipper/src/api/index.js new file mode 100644 index 0000000000..8dc873aa6c --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/src/api/index.js @@ -0,0 +1,73 @@ +import { Router } from "express" +import bodyParser from "body-parser" +import crypto from "crypto" +import cors from "cors" +import { getConfigFile } from "medusa-core-utils" + +export default (rootDirectory) => { + const app = Router() + + const { configModule } = getConfigFile(rootDirectory, "medusa-config") + const { projectConfig } = configModule + + const corsOptions = { + origin: projectConfig.store_cors.split(","), + credentials: true, + } + + app.options("/webshipper/drop-points/:rate_id", cors(corsOptions)) + app.get( + "/webshipper/drop-points/:rate_id", + cors(corsOptions), + async (req, res) => { + const { rate_id } = req.params + const { address_1, postal_code, country_code } = req.query + + try { + const webshipperService = req.scope.resolve( + "webshipperFulfillmentService" + ) + + const dropPoints = await webshipperService.retrieveDropPoints( + rate_id, + postal_code, + country_code, + address_1 + ) + + res.json({ + drop_points: dropPoints, + }) + } catch (err) { + res.json({ drop_points: [] }) + } + } + ) + + app.post( + "/webshipper/shipments", + bodyParser.raw({ type: "application/vnd.api+json" }), + async (req, res) => { + const eventBus = req.scope.resolve("eventBusService") + const logger = req.scope.resolve("logger") + + const secret = `da791d87513eb091640f9fb6c4b94384` + const hmac = crypto.createHmac("sha256", secret) + const digest = hmac.update(req.body).digest("base64") + const hash = req.header("x-webshipper-hmac-sha256") + + if (hash === digest) { + eventBus.emit("webshipper.shipment", { + headers: req.headers, + body: JSON.parse(req.body), + }) + } else { + logger.warn("Webshipper webhook could not be authenticated") + } + + res.sendStatus(200) + } + ) + + return app +} diff --git a/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js b/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js new file mode 100644 index 0000000000..a26782e08d --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js @@ -0,0 +1,239 @@ +import { FulfillmentService } from "medusa-interfaces" +import Webshipper from "../utils/webshipper" + +class WebshipperFulfillmentService extends FulfillmentService { + static identifier = "webshipper" + + constructor({ logger, orderService }, options) { + super() + + this.logger_ = logger + this.orderService_ = orderService + this.options_ = options + this.client_ = new Webshipper({ + account: this.options_.account, + token: this.options_.api_token, + }) + } + + registerInvoiceGenerator(service) { + if (typeof service.createInvoice === "function") { + this.invoiceGenerator_ = service + } + } + + async getFulfillmentOptions() { + const rates = await this.client_.shippingRates.list() + + return rates.data.map((r) => ({ + id: r.attributes.name, + webshipper_id: r.id, + name: r.attributes.name, + require_drop_point: r.attributes.require_drop_point, + carrier_id: r.attributes.carrier_id, + })) + } + + async validateFulfillmentData(data, _) { + if (data.require_drop_point) { + if (!data.drop_point_id) { + throw new Error("Must have drop point id") + } else { + // TODO: validate that the drop point exists + } + } + return data + } + + async validateOption(data) { + const rate = await this.client_.shippingRates + .retrieve(data.webshipper_id) + .catch(() => undefined) + return !!rate + } + + canCalculate() { + // Return whether or not we are able to calculate dynamically + return false + } + + calculatePrice() { + // Calculate prices + } + + async createOrder(methodData, fulfillmentItems, fromOrder) { + const existing = + fromOrder.metadata && fromOrder.metadata.webshipper_order_id + + let webshipperOrder + if (existing) { + webshipperOrder = await this.client_.orders.retrieve(existing) + } + + if (!webshipperOrder) { + let invoice + if (this.invoiceGenerator_) { + const base64Invoice = await this.invoiceGenerator_.createInvoice( + fromOrder, + fulfillmentItems + ) + + invoice = await this.client_.documents + .create({ + type: "documents", + attributes: { + document_size: this.options_.document_size || "A4", + document_format: "PDF", + base64: base64Invoice, + document_type: "invoice", + }, + }) + .catch((err) => { + throw err + }) + } + + const { shipping_address } = fromOrder + const newOrder = { + type: "orders", + attributes: { + status: "pending", + ext_ref: `${fromOrder._id}.${fromOrder.fulfillments.length}`, + visible_ref: `${fromOrder.display_id}-${ + fromOrder.fulfillments.length + 1 + }`, + order_lines: fulfillmentItems.map((item) => { + return { + ext_ref: item._id, + sku: item.content.variant.sku, + description: item.title, + quantity: item.quantity, + country_of_origin: + item.content.variant.metadata && + item.content.variant.metadata.origin_country, + tarif_number: + item.content.variant.metadata && + item.content.variant.metadata.hs_code, + unit_price: item.content.unit_price, + } + }), + delivery_address: { + att_contact: `${shipping_address.first_name} ${shipping_address.last_name}`, + address_1: shipping_address.address_1, + address_2: shipping_address.address_2, + zip: shipping_address.postal_code, + city: shipping_address.city, + country_code: shipping_address.country_code, + state: shipping_address.province, + phone: shipping_address.phone, + email: fromOrder.email, + }, + currency: fromOrder.currency_code, + }, + relationships: { + order_channel: { + data: { + id: this.options_.order_channel_id, + type: "order_channels", + }, + }, + shipping_rate: { + data: { + id: methodData.webshipper_id, + type: "shipping_rates", + }, + }, + }, + } + + if (methodData.require_drop_point) { + newOrder.attributes.drop_point = { + drop_point_id: methodData.drop_point_id, + name: methodData.drop_point_name, + zip: methodData.drop_point_zip, + address_1: methodData.drop_point_address_1, + city: methodData.drop_point_city, + country_code: methodData.drop_point_country_code, + } + } + if (invoice) { + newOrder.relationships.documents = { + data: [ + { + id: invoice.data.id, + type: invoice.data.type, + }, + ], + } + } + + return this.client_.orders + .create(newOrder) + .then((result) => { + return result.data + }) + .catch((err) => { + this.logger_.warn(err.response) + throw err + }) + } + } + + async handleWebhook(_, body) { + const wsOrder = await this.retrieveRelationship( + body.data.relationships.order + ) + if (wsOrder.data.attributes.ext_ref) { + const trackingNumbers = body.data.attributes.tracking_links.map( + (l) => l.number + ) + const [orderId, fulfillmentIndex] = wsOrder.data.attributes.ext_ref.split( + "." + ) + + const order = await this.orderService_.retrieve(orderId) + const fulfillment = order.fulfillments[fulfillmentIndex] + if (fulfillment) { + await this.orderService_.createShipment( + order._id, + fulfillment._id, + trackingNumbers + ) + } + } + } + + async retrieveDropPoints(id, zip, countryCode, address1) { + const points = await this.client_ + .request({ + method: "POST", + url: `/v2/drop_point_locators`, + data: { + data: { + type: "drop_point_locators", + attributes: { + shipping_rate_id: id, + delivery_address: { + zip, + country_code: countryCode, + address_1: address1, + }, + }, + }, + }, + }) + .then(({ data }) => data) + + return points.attributes.drop_points + } + + retrieveRelationship(relation) { + const link = relation.links.related + return this.client_.request({ + method: "GET", + url: link, + }) + } +} + +export default WebshipperFulfillmentService diff --git a/packages/medusa-fulfillment-webshipper/src/subscribers/webshipper.js b/packages/medusa-fulfillment-webshipper/src/subscribers/webshipper.js new file mode 100644 index 0000000000..a57823478b --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/src/subscribers/webshipper.js @@ -0,0 +1,15 @@ +class WebshipperSubscriber { + constructor({ eventBusService, logger, webshipperFulfillmentService }) { + this.webshipperService_ = webshipperFulfillmentService + + eventBusService.subscribe("webshipper.shipment", this.handleShipment) + } + + handleShipment = async ({ headers, body }) => { + return this.webshipperService_.handleWebhook(headers, body).catch((err) => { + logger.warn(err) + }) + } +} + +export default WebshipperSubscriber diff --git a/packages/medusa-fulfillment-webshipper/src/utils/webshipper.js b/packages/medusa-fulfillment-webshipper/src/utils/webshipper.js new file mode 100644 index 0000000000..995260cf2f --- /dev/null +++ b/packages/medusa-fulfillment-webshipper/src/utils/webshipper.js @@ -0,0 +1,97 @@ +import axios from "axios" + +class Webshipper { + constructor({ account, token }) { + this.account_ = account + this.token_ = token + this.client_ = axios.create({ + baseURL: `https://${account}.api.webshipper.io`, + headers: { + "content-type": "application/vnd.api+json", + Authorization: `Bearer ${token}`, + }, + }) + + this.documents = this.buildDocumentEndpoints_() + this.shippingRates = this.buildShippingRateEndpoints_() + this.orders = this.buildOrderEndpoints_() + this.shipments = this.buildShipmentEndpoints_() + } + + async request(data) { + return this.client_(data).then(({ data }) => data) + } + + buildDocumentEndpoints_ = () => { + return { + create: async (data) => { + const path = `/v2/documents` + return this.client_({ + method: "POST", + url: path, + data: { + data, + }, + }).then(({ data }) => data) + }, + } + } + + buildShippingRateEndpoints_ = () => { + return { + retrieve: async (id) => { + const path = `/v2/shipping_rates/${id}` + return this.client_({ + method: "GET", + url: path, + }).then(({ data }) => data) + }, + list: async () => { + const path = `/v2/shipping_rates` + return this.client_({ + method: "GET", + url: path, + }).then(({ data }) => data) + }, + } + } + + buildOrderEndpoints_ = () => { + return { + retrieve: async (id) => { + const path = `/v2/orders/${id}` + return this.client_({ + method: "GET", + url: path, + }).then(({ data }) => data) + }, + create: async (data) => { + const path = `/v2/orders` + return this.client_({ + method: "POST", + url: path, + data: { + data, + }, + }).then(({ data }) => data) + }, + } + } + + buildShipmentEndpoints_ = () => { + return { + create: async (data) => { + const path = `/v2/shipments` + return this.client_({ + method: "POST", + url: path, + data: { + data, + }, + }).then(({ data }) => data) + }, + } + } +} + +export default Webshipper diff --git a/packages/medusa-interfaces/src/fulfillment-service.js b/packages/medusa-interfaces/src/fulfillment-service.js index 9de1b03357..892a0d055c 100644 --- a/packages/medusa-interfaces/src/fulfillment-service.js +++ b/packages/medusa-interfaces/src/fulfillment-service.js @@ -15,12 +15,36 @@ class BaseFulfillmentService extends BaseService { return this.constructor.identifier } + /** + * Called before a shipping option is created in Admin. The method should + * return all of the options that the fulfillment provider can be used with, + * and it is here the distinction between different shipping options are + * enforced. For example, a fulfillment provider may offer Standard Shipping + * and Express Shipping as fulfillment options, it is up to the store operator + * to create shipping options in Medusa that can be chosen between by the + * customer. + */ getFulfillmentOptions() {} + /** + * Called before a shipping method is set on a cart to ensure that the data + * sent with the shipping method is valid. The data object may contain extra + * data about the shipment such as an id of a drop point. It is up to the + * fulfillment provider to enforce that the correct data is being sent + * through. + * @param {object} data - the data to validate + * @param {object} cart - the cart to which the shipping method will be applied + * @return {object} the data to populate `cart.shipping_methods.$.data` this + * is usually important for future actions like generating shipping labels + */ validateFulfillmentData(data, cart) { throw Error("validateFulfillmentData must be overridden by the child class") } + /** + * Called before a shipping option is created in Admin. Use this to ensure + * that a fulfillment option does in fact exist. + */ validateOption(data) { throw Error("validateOption must be overridden by the child class") } @@ -29,7 +53,10 @@ class BaseFulfillmentService extends BaseService { throw Error("canCalculate must be overridden by the child class") } - calculatePrice(data) { + /** + * Used to calculate a price for a given shipping option. + */ + calculatePrice(data, cart) { throw Error("calculatePrice must be overridden by the child class") } diff --git a/packages/medusa/src/loaders/plugins.js b/packages/medusa/src/loaders/plugins.js index 1e3d856169..a244199ccb 100644 --- a/packages/medusa/src/loaders/plugins.js +++ b/packages/medusa/src/loaders/plugins.js @@ -18,10 +18,7 @@ import { sync as existsSync } from "fs-exists-cached" * Registers all services in the services directory */ export default async ({ rootDirectory, container, app }) => { - const { configModule, configFilePath } = getConfigFile( - rootDirectory, - `medusa-config` - ) + const { configModule } = getConfigFile(rootDirectory, `medusa-config`) if (!configModule) { return @@ -53,7 +50,7 @@ export default async ({ rootDirectory, container, app }) => { registerModels(pluginDetails, container) await registerServices(pluginDetails, container) registerMedusaApi(pluginDetails, container) - registerApi(pluginDetails, app, rootDirectory) + registerApi(pluginDetails, app, rootDirectory, container) registerCoreRouters(pluginDetails, container) registerSubscribers(pluginDetails, container) }) @@ -143,12 +140,22 @@ function registerCoreRouters(pluginDetails, container) { /** * Registers the plugin's api routes. */ -function registerApi(pluginDetails, app, rootDirectory = "") { +function registerApi(pluginDetails, app, rootDirectory = "", container) { + const logger = container.resolve("logger") + logger.info(`Registering custom endpoints for ${pluginDetails.name}`) try { const routes = require(`${pluginDetails.resolve}/api`).default - app.use("/", routes(rootDirectory)) + if (routes) { + app.use("/", routes(rootDirectory)) + } return app } catch (err) { + if (err.message !== `Cannot find module '${pluginDetails.resolve}/api'`) { + logger.warn( + `An error occured while registering customer endpoints for ${pluginDetails.name}` + ) + logger.error(err.stack) + } return app } } @@ -218,7 +225,7 @@ async function registerServices(pluginDetails, container) { container.register({ [name]: asFunction( cradle => new loaded(cradle, pluginDetails.options) - ), + ).singleton(), [`fp_${loaded.identifier}`]: aliasTo(name), }) } else if (loaded.prototype instanceof FileService) {