601 lines
17 KiB
JavaScript
601 lines
17 KiB
JavaScript
import { FulfillmentService } from "medusa-interfaces"
|
|
import Webshipper from "../utils/webshipper"
|
|
|
|
class WebshipperFulfillmentService extends FulfillmentService {
|
|
static identifier = "webshipper"
|
|
|
|
constructor({ logger, claimService, swapService, orderService }, options) {
|
|
super()
|
|
|
|
this.options_ = options
|
|
|
|
if (!options.coo_countries) {
|
|
this.options_.coo_countries = ["all"]
|
|
} else if (Array.isArray(options.coo_countries)) {
|
|
this.options_.coo_countries = options.coo_countries.map((c) =>
|
|
c.toLowerCase()
|
|
)
|
|
} else if (typeof options.coo_countries === "string") {
|
|
this.options_.coo_countries = [options.coo_countries]
|
|
}
|
|
|
|
/** @private @const {logger} */
|
|
this.logger_ = logger
|
|
|
|
/** @private @const {OrderService} */
|
|
this.orderService_ = orderService
|
|
|
|
/** @private @const {SwapService} */
|
|
this.swapService_ = swapService
|
|
|
|
/** @private @const {SwapService} */
|
|
this.claimService_ = claimService
|
|
|
|
/** @private @const {AxiosClient} */
|
|
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({
|
|
order_channel_id: this.options_.order_channel_id,
|
|
})
|
|
|
|
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,
|
|
is_return: r.attributes.is_return,
|
|
}))
|
|
}
|
|
|
|
async validateFulfillmentData(optionData, data, _) {
|
|
if (optionData.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 {
|
|
...optionData,
|
|
...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
|
|
}
|
|
|
|
/**
|
|
* Creates a return shipment in webshipper using the given method data, and
|
|
* return lines.
|
|
*/
|
|
async createReturn(returnOrder) {
|
|
let orderId
|
|
if (returnOrder.order_id) {
|
|
orderId = returnOrder.order_id
|
|
} else if (returnOrder.swap) {
|
|
orderId = returnOrder.swap.order_id
|
|
} else if (returnOrder.claim_order) {
|
|
orderId = returnOrder.claim_order.order_id
|
|
}
|
|
|
|
const fromOrder = await this.orderService_.retrieve(orderId, {
|
|
select: ["total"],
|
|
relations: ["discounts", "shipping_address", "returns"],
|
|
})
|
|
|
|
const methodData = returnOrder.shipping_method.data
|
|
|
|
const relationships = {
|
|
shipping_rate: {
|
|
data: {
|
|
type: "shipping_rates",
|
|
id: methodData.webshipper_id,
|
|
},
|
|
},
|
|
}
|
|
|
|
const existing =
|
|
fromOrder.metadata && fromOrder.metadata.webshipper_order_id
|
|
if (existing) {
|
|
relationships.order = {
|
|
data: {
|
|
type: "orders",
|
|
id: existing,
|
|
},
|
|
}
|
|
}
|
|
|
|
let docs = []
|
|
if (this.invoiceGenerator_) {
|
|
const base64Invoice = await this.invoiceGenerator_.createReturnInvoice(
|
|
fromOrder,
|
|
returnOrder.items
|
|
)
|
|
|
|
docs.push({
|
|
document_size: "A4",
|
|
document_format: "PDF",
|
|
base64: base64Invoice,
|
|
document_type: "invoice",
|
|
})
|
|
}
|
|
|
|
const { shipping_address } = fromOrder
|
|
const returnShipment = {
|
|
type: "shipments",
|
|
attributes: {
|
|
reference: `R${fromOrder.display_id}-${fromOrder.returns.length + 1}`,
|
|
ext_ref: `${fromOrder.id}.${returnOrder.id}`,
|
|
is_return: true,
|
|
included_documents: docs,
|
|
packages: [
|
|
{
|
|
weight: 500,
|
|
weight_unit: "g",
|
|
dimensions: {
|
|
unit: "cm",
|
|
height: 15,
|
|
width: 15,
|
|
length: 15,
|
|
},
|
|
customs_lines: returnOrder.items.map(({ item, quantity }) => {
|
|
return {
|
|
ext_ref: item.id,
|
|
sku: item.variant.sku,
|
|
description: item.title,
|
|
quantity: quantity,
|
|
country_of_origin:
|
|
item.variant.origin_country ||
|
|
item.variant.product.origin_country,
|
|
tarif_number:
|
|
item.variant.hs_code || item.variant.product.hs_code,
|
|
unit_price: item.unit_price / 100,
|
|
vat_percent: fromOrder.tax_rate,
|
|
currency: fromOrder.currency_code.toUpperCase(),
|
|
}
|
|
}),
|
|
},
|
|
],
|
|
sender_address: {
|
|
att_contact: `${shipping_address.first_name} ${shipping_address.last_name}`,
|
|
// Some carriers require a company_name
|
|
company_name: `${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.toUpperCase(),
|
|
state: shipping_address.province,
|
|
phone: shipping_address.phone,
|
|
email: fromOrder.email,
|
|
},
|
|
delivery_address: this.options_.return_address,
|
|
},
|
|
relationships,
|
|
}
|
|
|
|
return this.client_.shipments
|
|
.create(returnShipment)
|
|
.then((result) => {
|
|
return result.data
|
|
})
|
|
.catch((err) => {
|
|
this.logger_.warn(err.response)
|
|
throw err
|
|
})
|
|
}
|
|
|
|
async getReturnDocuments(data) {
|
|
const shipment = await this.client_.shipments.retrieve(data.id)
|
|
const labels = await this.retrieveRelationship(
|
|
shipment.data.relationships.labels
|
|
).then((res) => res.data)
|
|
const docs = await this.retrieveRelationship(
|
|
shipment.data.relationships.documents
|
|
).then((res) => res.data)
|
|
const toReturn = []
|
|
for (const d of labels) {
|
|
toReturn.push({
|
|
name: "Return label",
|
|
base_64: d.attributes.base64,
|
|
type: "pdf",
|
|
})
|
|
}
|
|
for (const d of docs) {
|
|
toReturn.push({
|
|
name: d.attributes.document_type,
|
|
base_64: d.attributes.base64,
|
|
type: "pdf",
|
|
})
|
|
}
|
|
|
|
return toReturn
|
|
}
|
|
|
|
async createFulfillment(
|
|
methodData,
|
|
fulfillmentItems,
|
|
fromOrder,
|
|
fulfillment
|
|
) {
|
|
const existing =
|
|
fromOrder.metadata && fromOrder.metadata.webshipper_order_id
|
|
|
|
let webshipperOrder
|
|
if (existing) {
|
|
webshipperOrder = await this.client_.orders.retrieve(existing)
|
|
}
|
|
|
|
const { shipping_address } = fromOrder
|
|
|
|
if (!webshipperOrder) {
|
|
let invoice
|
|
let certificateOfOrigin
|
|
|
|
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 cooCountries = this.options_.coo_countries
|
|
if (
|
|
(cooCountries.includes("all") ||
|
|
cooCountries.includes(
|
|
shipping_address.country_code.toLowerCase()
|
|
)) &&
|
|
this.invoiceGenerator_.createCertificateOfOrigin
|
|
) {
|
|
const base64Coo = await this.invoiceGenerator_.createCertificateOfOrigin(
|
|
fromOrder,
|
|
fulfillmentItems
|
|
)
|
|
|
|
certificateOfOrigin = await this.client_.documents
|
|
.create({
|
|
type: "documents",
|
|
attributes: {
|
|
document_size: this.options_.document_size || "A4",
|
|
document_format: "PDF",
|
|
base64: base64Coo,
|
|
document_type: "certificate",
|
|
},
|
|
})
|
|
.catch((err) => {
|
|
throw err
|
|
})
|
|
}
|
|
}
|
|
|
|
let id = fulfillment.id
|
|
let visible_ref = `${fromOrder.display_id}-${id.substr(id.length - 4)}`
|
|
let ext_ref = `${fromOrder.id}.${fulfillment.id}`
|
|
|
|
if (fromOrder.is_swap) {
|
|
ext_ref = `${fromOrder.id}.${fulfillment.id}`
|
|
visible_ref = `S-${fromOrder.display_id}`
|
|
}
|
|
|
|
const newOrder = {
|
|
type: "orders",
|
|
attributes: {
|
|
status: "pending",
|
|
ext_ref,
|
|
visible_ref,
|
|
order_lines: fulfillmentItems.map((item) => {
|
|
return {
|
|
ext_ref: item.id,
|
|
sku: item.variant.sku,
|
|
description: item.title,
|
|
quantity: item.quantity,
|
|
country_of_origin:
|
|
item.variant.origin_country ||
|
|
item.variant.product.origin_country,
|
|
tarif_number:
|
|
item.variant.hs_code || item.variant.product.hs_code,
|
|
unit_price: item.unit_price / 100,
|
|
vat_percent: fromOrder.tax_rate,
|
|
}
|
|
}),
|
|
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.toUpperCase(),
|
|
state: shipping_address.province,
|
|
phone: shipping_address.phone,
|
|
email: fromOrder.email,
|
|
},
|
|
currency: fromOrder.currency_code.toUpperCase(),
|
|
},
|
|
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.toUpperCase(),
|
|
}
|
|
}
|
|
|
|
if (invoice || certificateOfOrigin) {
|
|
const docData = []
|
|
if (invoice) {
|
|
docData.push({
|
|
id: invoice.data.id,
|
|
type: invoice.data.type,
|
|
})
|
|
}
|
|
|
|
if (certificateOfOrigin) {
|
|
docData.push({
|
|
id: certificateOfOrigin.data.id,
|
|
type: certificateOfOrigin.data.type,
|
|
})
|
|
}
|
|
|
|
newOrder.relationships.documents = {
|
|
data: docData,
|
|
}
|
|
}
|
|
|
|
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 && wsOrder.data.attributes.ext_ref) {
|
|
const trackingLinks = body.data.attributes.tracking_links.map((l) => ({
|
|
url: l.url,
|
|
tracking_number: l.number,
|
|
}))
|
|
const [orderId, fulfillmentIndex] = wsOrder.data.attributes.ext_ref.split(
|
|
"."
|
|
)
|
|
|
|
if (orderId.charAt(0).toLowerCase() === "s") {
|
|
if (fulfillmentIndex.startsWith("ful")) {
|
|
return this.swapService_.createShipment(
|
|
orderId,
|
|
fulfillmentIndex,
|
|
trackingLinks
|
|
)
|
|
} else {
|
|
const swap = await this.swapService_.retrieve(orderId.substring(1), {
|
|
relations: ["fulfillments"],
|
|
})
|
|
const fulfillment = swap.fulfillments[fulfillmentIndex]
|
|
return this.swapService_.createShipment(
|
|
swap.id,
|
|
fulfillment.id,
|
|
trackingLinks
|
|
)
|
|
}
|
|
} else if (orderId.charAt(0).toLowerCase() === "c") {
|
|
return this.claimService_.createShipment(
|
|
orderId,
|
|
fulfillmentIndex,
|
|
trackingLinks
|
|
)
|
|
} else {
|
|
if (fulfillmentIndex.startsWith("ful")) {
|
|
return this.orderService_.createShipment(
|
|
orderId,
|
|
fulfillmentIndex,
|
|
trackingLinks
|
|
)
|
|
} else {
|
|
const order = await this.orderService_.retrieve(orderId, {
|
|
relations: ["fulfillments"],
|
|
})
|
|
|
|
const fulfillment = order.fulfillments[fulfillmentIndex]
|
|
if (fulfillment) {
|
|
return this.orderService_.createShipment(
|
|
order.id,
|
|
fulfillment.id,
|
|
trackingLinks
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This plugin doesn't support shipment documents.
|
|
*/
|
|
async retrieveDocuments(fulfillmentData, documentType) {
|
|
switch (documentType) {
|
|
case "label":
|
|
const labelRelation = fulfillmentData?.relationships?.labels
|
|
if (labelRelation) {
|
|
const docs = await this.retrieveRelationship(labelRelation)
|
|
.then(({ data }) => data)
|
|
.catch((_) => [])
|
|
|
|
return docs.map((d) => ({
|
|
name: d.attributes.document_type,
|
|
base_64: d.attributes.base64,
|
|
type: "application/pdf",
|
|
}))
|
|
}
|
|
return []
|
|
|
|
case "invoice":
|
|
const docRelation = fulfillmentData?.relationships?.documents
|
|
if (docRelation) {
|
|
const docs = await this.retrieveRelationship(docRelation)
|
|
.then(({ data }) => data)
|
|
.catch((_) => [])
|
|
|
|
return docs.map((d) => ({
|
|
name: d.attributes.document_type,
|
|
base_64: d.attributes.base64,
|
|
type: "application/pdf",
|
|
}))
|
|
}
|
|
return []
|
|
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves the documents associated with an order.
|
|
* @return {Promise<Array<_>>} an array of document objects to store in the
|
|
* database.
|
|
*/
|
|
async getFulfillmentDocuments(data) {
|
|
const order = await this.client_.orders.retrieve(data.id)
|
|
const docs = await this.retrieveRelationship(
|
|
order.data.relationships.documents
|
|
).then((res) => res.data)
|
|
return docs.map((d) => ({
|
|
name: d.attributes.document_type,
|
|
base_64: d.attributes.base64,
|
|
type: "pdf",
|
|
}))
|
|
}
|
|
|
|
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.toUpperCase(),
|
|
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,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Cancels a fulfillment. If the fulfillment has already been canceled this
|
|
* is idemptotent. Can only cancel pending orders.
|
|
* @param {object} data - the fulfilment data
|
|
* @return {Promise<object>} the result of the cancellation
|
|
*/
|
|
async cancelFulfillment(data) {
|
|
if (Array.isArray(data)) {
|
|
data = data[0]
|
|
}
|
|
|
|
const order = await this.client_.orders
|
|
.retrieve(data.id)
|
|
.catch(() => undefined)
|
|
|
|
// if order does not exist, we resolve gracefully
|
|
if (!order) {
|
|
return Promise.resolve()
|
|
}
|
|
|
|
if (order) {
|
|
if (
|
|
order.data.attributes.status !== "pending" &&
|
|
order.data.attributes.status !== "missing_rate"
|
|
) {
|
|
if (order.data.attributes.status === "cancelled") {
|
|
return Promise.resolve(order)
|
|
}
|
|
throw new Error("Cannot cancel order")
|
|
}
|
|
}
|
|
|
|
return this.client_.orders.delete(data.id)
|
|
}
|
|
}
|
|
|
|
export default WebshipperFulfillmentService
|