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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user