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