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
This commit is contained in:
Sebastian Rindom
2020-10-05 08:44:12 +02:00
committed by GitHub
parent 89c21f2f5b
commit 893a7f69af
14 changed files with 577 additions and 9 deletions

View File

@@ -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"]
}
}
}

View File

@@ -0,0 +1,9 @@
{
"plugins": ["prettier"],
"extends": ["prettier"],
"rules": {
"prettier/prettier": "error",
"semi": "error",
"no-unused-expressions": "true"
}
}

View File

@@ -0,0 +1,14 @@
/lib
node_modules
.DS_store
.env*
/*.js
!index.js
yarn.lock
/api
/services
/models
/subscribers
/utils

View File

@@ -0,0 +1,10 @@
/lib
node_modules
.DS_store
.env*
/*.js
!index.js
yarn.lock
/src

View File

@@ -0,0 +1,7 @@
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}

View File

@@ -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)
```

View File

@@ -0,0 +1 @@
// noop

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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) {