From 17d91c276a68de4d2b360335f32fcce48a16c9ec Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Sun, 3 Sep 2023 20:05:36 +0200 Subject: [PATCH] feat(medusa): Add AbstractFulfillmentService (#4922) * feat(medusa): Add abstract fulfillment service * add data types * add loaders * Create nine-glasses-allow.md * address pr comments --- .changeset/nine-glasses-allow.md | 6 + .../src/services/webshipper-fulfillment.js | 20 +-- .../src/interfaces/fulfillment-service.ts | 169 ++++++++++++++++++ packages/medusa/src/interfaces/index.ts | 1 + packages/medusa/src/loaders/defaults.ts | 37 ++-- .../medusa/src/loaders/helpers/plugins.ts | 69 ++++++- packages/medusa/src/loaders/plugins.ts | 51 ++---- 7 files changed, 281 insertions(+), 72 deletions(-) create mode 100644 .changeset/nine-glasses-allow.md create mode 100644 packages/medusa/src/interfaces/fulfillment-service.ts diff --git a/.changeset/nine-glasses-allow.md b/.changeset/nine-glasses-allow.md new file mode 100644 index 0000000000..265be6ee61 --- /dev/null +++ b/.changeset/nine-glasses-allow.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa": patch +"medusa-fulfillment-webshipper": minor +--- + +[wip] feat(medusa): Add AbstractFulfillmentService diff --git a/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js b/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js index 986590da5b..0f13e15b6b 100644 --- a/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js +++ b/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js @@ -1,8 +1,8 @@ import { humanizeAmount } from "medusa-core-utils" -import { FulfillmentService } from "medusa-interfaces" import Webshipper from "../utils/webshipper" +import { AbstractFulfillmentService } from "@medusajs/medusa" -class WebshipperFulfillmentService extends FulfillmentService { +class WebshipperFulfillmentService extends AbstractFulfillmentService { static identifier = "webshipper" constructor( @@ -97,10 +97,6 @@ class WebshipperFulfillmentService extends FulfillmentService { // 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) { @@ -477,13 +473,11 @@ class WebshipperFulfillmentService extends FulfillmentService { } } - /** - * This plugin doesn't support shipment documents. - */ async retrieveDocuments(fulfillmentData, documentType) { + const labelRelation = fulfillmentData?.relationships?.labels + const docRelation = fulfillmentData?.relationships?.documents switch (documentType) { case "label": - const labelRelation = fulfillmentData?.relationships?.labels if (labelRelation) { const docs = await this.retrieveRelationship(labelRelation) .then(({ data }) => data) @@ -498,7 +492,6 @@ class WebshipperFulfillmentService extends FulfillmentService { return [] case "invoice": - const docRelation = fulfillmentData?.relationships?.documents if (docRelation) { const docs = await this.retrieveRelationship(docRelation) .then(({ data }) => data) @@ -517,11 +510,6 @@ class WebshipperFulfillmentService extends FulfillmentService { } } - /** - * 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( diff --git a/packages/medusa/src/interfaces/fulfillment-service.ts b/packages/medusa/src/interfaces/fulfillment-service.ts new file mode 100644 index 0000000000..4760111703 --- /dev/null +++ b/packages/medusa/src/interfaces/fulfillment-service.ts @@ -0,0 +1,169 @@ +import { MedusaContainer } from "@medusajs/types" +import { Cart, Fulfillment, LineItem, Order } from "../models" +import { CreateReturnType } from "../types/fulfillment-provider" + +type FulfillmentProviderData = Record +type ShippingOptionData = Record +type ShippingMethodData = Record + +/** + * Fulfillment Provider interface + * Fullfillment provider plugin services should extend the AbstractFulfillmentService from this file + */ +export interface FulfillmentService { + /** + * Return a unique identifier to retrieve the fulfillment plugin provider + */ + getIdentifier(): string + + /** + * 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 are offered to the customer. + */ + getFulfillmentOptions(): Promise + + /** + * 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. + * @return the data to populate `cart.shipping_methods.$.data` this + * is usually important for future actions like generating shipping labels + */ + validateFulfillmentData( + optionData: ShippingOptionData, + data: FulfillmentProviderData, + cart: Cart + ): Promise> + + /** + * Called before a shipping option is created in Admin. Use this to ensure + * that a fulfillment option does in fact exist. + */ + validateOption(data: ShippingOptionData): Promise + + /** + * Used to determine if a shipping option can have a calculated price + */ + canCalculate(data: ShippingOptionData): Promise + + /** + * Used to calculate a price for a given shipping option. + */ + calculatePrice( + optionData: ShippingOptionData, + data: FulfillmentProviderData, + cart: Cart + ): Promise + + /** + * Create a fulfillment using data from shipping method, line items, and fulfillment. All from the order. + * The returned value of this method will populate the `fulfillment.data` field. + */ + createFulfillment( + data: ShippingMethodData, + items: LineItem, + order: Order, + fulfillment: Fulfillment + ): Promise + + /** + * Cancel a fulfillment using data from the fulfillment + */ + cancelFulfillment(fulfillmentData: FulfillmentProviderData): Promise + + /** + * Used to create a return order. Should return the data necessary for future + * operations on the return; in particular the data may be used to receive + * documents attached to the return. + */ + createReturn(returnOrder: CreateReturnType): Promise> + + /** + * Used to retrieve documents associated with a fulfillment. + */ + getFulfillmentDocuments(data: FulfillmentProviderData): Promise + + /** + * Used to retrieve documents related to a return order. + */ + getReturnDocuments(data: Record): Promise + + /** + * Used to retrieve documents related to a shipment. + */ + getShipmentDocuments(data: Record): Promise + + retrieveDocuments( + fulfillmentData: FulfillmentProviderData, + documentType: "invoice" | "label" + ): Promise +} + +export abstract class AbstractFulfillmentService implements FulfillmentService { + protected constructor( + protected readonly container: MedusaContainer, + protected readonly config?: Record // eslint-disable-next-line @typescript-eslint/no-empty-function + ) {} + + public static identifier: string + + public getIdentifier(): string { + const ctr = this.constructor as typeof AbstractFulfillmentService + + if (!ctr.identifier) { + throw new Error(`Missing static property "identifier".`) + } + + return ctr.identifier + } + + abstract getFulfillmentOptions(): Promise + + abstract validateFulfillmentData( + optionData: ShippingOptionData, + data: FulfillmentProviderData, + cart: Cart + ): Promise> + + abstract validateOption(data: ShippingOptionData): Promise + + abstract canCalculate(data: ShippingOptionData): Promise + + abstract calculatePrice( + optionData: ShippingOptionData, + data: FulfillmentProviderData, + cart: Cart + ): Promise + + abstract createFulfillment( + data: ShippingMethodData, + items: LineItem, + order: Order, + fulfillment: Fulfillment + ): Promise + + abstract cancelFulfillment(fulfillment: FulfillmentProviderData): Promise + + abstract createReturn( + returnOrder: CreateReturnType + ): Promise> + + abstract getFulfillmentDocuments(data: FulfillmentProviderData): Promise + + abstract getReturnDocuments(data: Record): Promise + + abstract getShipmentDocuments(data: Record): Promise + + abstract retrieveDocuments( + fulfillmentData: Record, + documentType: "invoice" | "label" + ): Promise +} + +export default AbstractFulfillmentService diff --git a/packages/medusa/src/interfaces/index.ts b/packages/medusa/src/interfaces/index.ts index 0c60405bd9..fcda0f172d 100644 --- a/packages/medusa/src/interfaces/index.ts +++ b/packages/medusa/src/interfaces/index.ts @@ -1,6 +1,7 @@ export * from "./batch-job-strategy" export * from "./cart-completion-strategy" export * from "./file-service" +export * from "./fulfillment-service" export * from "./models/base-entity" export * from "./models/soft-deletable-entity" export * from "./notification-service" diff --git a/packages/medusa/src/loaders/defaults.ts b/packages/medusa/src/loaders/defaults.ts index a66841c119..80c381e8aa 100644 --- a/packages/medusa/src/loaders/defaults.ts +++ b/packages/medusa/src/loaders/defaults.ts @@ -1,26 +1,27 @@ import { FlagRouter } from "@medusajs/utils" import { AwilixContainer } from "awilix" import { - BaseFulfillmentService, - BaseNotificationService, - BasePaymentService, + BaseFulfillmentService, + BaseNotificationService, + BasePaymentService, } from "medusa-interfaces" import { EntityManager } from "typeorm" import { - AbstractPaymentProcessor, - AbstractPaymentService, - AbstractTaxService, + AbstractFulfillmentService, + AbstractPaymentProcessor, + AbstractPaymentService, + AbstractTaxService, } from "../interfaces" import { CountryRepository } from "../repositories/country" import { CurrencyRepository } from "../repositories/currency" import { - FulfillmentProviderService, - NotificationService, - PaymentProviderService, - SalesChannelService, - ShippingProfileService, - StoreService, - TaxProviderService, + FulfillmentProviderService, + NotificationService, + PaymentProviderService, + SalesChannelService, + ShippingProfileService, + StoreService, + TaxProviderService, } from "../services" import { Logger } from "../types/global" import { countries } from "../utils/countries" @@ -227,7 +228,7 @@ async function registerNotificationProvider({ logger: Logger }): Promise { const notiProviders = - silentResolution( + silentResolution<(typeof BaseNotificationService)[]>( container, "notificationProviders", logger @@ -252,11 +253,9 @@ async function registerFulfillmentProvider({ logger: Logger }): Promise { const fulfilProviders = - silentResolution( - container, - "fulfillmentProviders", - logger - ) || [] + silentResolution< + (typeof BaseFulfillmentService | AbstractFulfillmentService)[] + >(container, "fulfillmentProviders", logger) || [] const fulfilIds = fulfilProviders.map((p) => p.getIdentifier()) const fProviderService = container.resolve( diff --git a/packages/medusa/src/loaders/helpers/plugins.ts b/packages/medusa/src/loaders/helpers/plugins.ts index 16252dd3db..1d2c70a33e 100644 --- a/packages/medusa/src/loaders/helpers/plugins.ts +++ b/packages/medusa/src/loaders/helpers/plugins.ts @@ -1,11 +1,13 @@ -import { ClassConstructor, MedusaContainer } from "../../types/global" +import { Lifetime, LifetimeType, aliasTo, asFunction } from "awilix" +import { FulfillmentService } from "medusa-interfaces" import { + AbstractFulfillmentService, AbstractPaymentProcessor, AbstractPaymentService, isPaymentProcessor, isPaymentService, } from "../../interfaces" -import { aliasTo, asFunction, Lifetime, LifetimeType } from "awilix" +import { ClassConstructor, MedusaContainer } from "../../types/global" type Context = { container: MedusaContainer @@ -74,3 +76,66 @@ export function registerPaymentProcessorFromClass( aliasTo(registrationName), }) } + +export function registerAbstractFulfillmentServiceFromClass( + klass: ClassConstructor & { + LIFE_TIME?: LifetimeType + }, + context: Context +): void { + if (!(klass.prototype instanceof AbstractFulfillmentService)) { + return + } + + const { container, pluginDetails, registrationName } = context + + container.registerAdd( + "fulfillmentProviders", + asFunction((cradle) => new klass(cradle, pluginDetails.options), { + lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, + }) + ) + + container.register({ + [registrationName]: asFunction( + (cradle) => new klass(cradle, pluginDetails.options), + { + lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, + } + ), + [`fp_${ + (klass as unknown as typeof AbstractFulfillmentService).identifier + }`]: aliasTo(registrationName), + }) +} + +export function registerFulfillmentServiceFromClass( + klass: ClassConstructor & { + LIFE_TIME?: LifetimeType + }, + context: Context +): void { + if (!(klass.prototype instanceof FulfillmentService)) { + return + } + + const { container, pluginDetails, registrationName } = context + + container.registerAdd( + "fulfillmentProviders", + asFunction((cradle) => new klass(cradle, pluginDetails.options), { + lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, + }) + ) + + container.register({ + [registrationName]: asFunction( + (cradle) => new klass(cradle, pluginDetails.options), + { + lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, + } + ), + [`fp_${(klass as unknown as typeof FulfillmentService).identifier}`]: + aliasTo(registrationName), + }) +} diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index 1bfcbbfdcf..d174c87574 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -1,3 +1,6 @@ +import { SearchUtils, upperCaseFirst } from "@medusajs/utils" +import { Lifetime, aliasTo, asFunction, asValue } from "awilix" +import { FileService, OauthService } from "medusa-interfaces" import { AbstractTaxService, isBatchJobStrategy, @@ -13,34 +16,29 @@ import { Logger, MedusaContainer, } from "../types/global" -import { - FileService, - FulfillmentService, - OauthService, -} from "medusa-interfaces" -import { Lifetime, aliasTo, asFunction, asValue } from "awilix" -import { SearchUtils, upperCaseFirst } from "@medusajs/utils" import { formatRegistrationName, formatRegistrationNameWithoutNamespace, } from "../utils/format-registration-name" import { + registerAbstractFulfillmentServiceFromClass, + registerFulfillmentServiceFromClass, registerPaymentProcessorFromClass, registerPaymentServiceFromClass, } from "./helpers/plugins" -import { EntitySchema } from "typeorm" import { Express } from "express" -import { MiddlewareService } from "../services" +import fs from "fs" +import { sync as existsSync } from "fs-exists-cached" +import glob from "glob" import _ from "lodash" import { createRequireFromPath } from "medusa-core-utils" -import { sync as existsSync } from "fs-exists-cached" -import fs from "fs" -import { getModelExtensionsMap } from "./helpers/get-model-extension-map" -import glob from "glob" -import logger from "./logger" -import path from "path" import { trackInstallation } from "medusa-telemetry" +import path from "path" +import { EntitySchema } from "typeorm" +import { MiddlewareService } from "../services" +import { getModelExtensionsMap } from "./helpers/get-model-extension-map" +import logger from "./logger" type Options = { rootDirectory: string @@ -396,6 +394,9 @@ export async function registerServices( registerPaymentServiceFromClass(loaded, context) registerPaymentProcessorFromClass(loaded, context) + registerFulfillmentServiceFromClass(loaded, context) + registerAbstractFulfillmentServiceFromClass(loaded, context) + if (loaded.prototype instanceof OauthService) { const appDetails = loaded.getAppDetails(pluginDetails.options) @@ -412,26 +413,6 @@ export async function registerServices( } ), }) - } else if (loaded.prototype instanceof FulfillmentService) { - // Register our payment providers to paymentProviders - container.registerAdd( - "fulfillmentProviders", - asFunction((cradle) => new loaded(cradle, pluginDetails.options), { - lifetime: loaded.LIFE_TIME || Lifetime.SINGLETON, - }) - ) - - // Add the service directly to the container in order to make simple - // resolution if we already know which fulfillment provider we need to use - container.register({ - [name]: asFunction( - (cradle) => new loaded(cradle, pluginDetails.options), - { - lifetime: loaded.LIFE_TIME || Lifetime.SINGLETON, - } - ), - [`fp_${loaded.identifier}`]: aliasTo(name), - }) } else if (isNotificationService(loaded.prototype)) { container.registerAdd( "notificationProviders",