diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index 9c10a9ac4aaee..044ba62b7e30a 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -1,5 +1,5 @@ import { - addShippingMethodToWorkflow, + addShippingMethodToCartWorkflow, addToCartWorkflow, createCartWorkflow, createPaymentCollectionForCartWorkflow, @@ -1638,22 +1638,7 @@ medusaIntegrationTestRunner({ }, ]) - await addShippingMethodToWorkflow(appContainer).run({ - input: { - options: [{ id: shippingOption.id }], - cart_id: cart.id, - }, - }) - - await addShippingMethodToWorkflow(appContainer).run({ - input: { - options: [{ id: shippingOption.id }], - cart_id: cart.id, - }, - }) - - // should remove the previous shipping method - await addShippingMethodToWorkflow(appContainer).run({ + await addShippingMethodToCartWorkflow(appContainer).run({ input: { options: [{ id: shippingOption.id }], cart_id: cart.id, @@ -1707,7 +1692,7 @@ medusaIntegrationTestRunner({ }, ]) - const { errors } = await addShippingMethodToWorkflow( + const { errors } = await addShippingMethodToCartWorkflow( appContainer ).run({ input: { @@ -1729,7 +1714,7 @@ medusaIntegrationTestRunner({ }) it("should throw error when shipping option is not present in the db", async () => { - const { errors } = await addShippingMethodToWorkflow( + const { errors } = await addShippingMethodToCartWorkflow( appContainer ).run({ input: { @@ -1749,6 +1734,103 @@ medusaIntegrationTestRunner({ }), ]) }) + + it("should add shipping method with custom data", async () => { + const stockLocation = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + await remoteLink.create([ + { + [Modules.STOCK_LOCATION]: { + stock_location_id: stockLocation.id, + }, + [Modules.FULFILLMENT]: { + fulfillment_set_id: fulfillmentSet.id, + }, + }, + ]) + + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-providers`, + { add: ["manual_test-provider"] }, + adminHeaders + ) + + const shippingOption = await fulfillmentModule.createShippingOptions({ + name: "Test shipping option", + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: "manual_test-provider", + price_type: "flat", + type: { + label: "Test type", + description: "Test description", + code: "test-code", + }, + rules: [ + { + operator: RuleOperator.EQ, + attribute: "is_return", + value: "false", + }, + { + operator: RuleOperator.EQ, + attribute: "enabled_in_store", + value: "true", + }, + ], + }) + + await remoteLink.create([ + { + [Modules.FULFILLMENT]: { shipping_option_id: shippingOption.id }, + [Modules.PRICING]: { price_set_id: priceSet.id }, + }, + ]) + + await addShippingMethodToCartWorkflow(appContainer).run({ + input: { + options: [{ id: shippingOption.id, data: { test: "test" } }], + cart_id: cart.id, + }, + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + select: ["id"], + relations: ["shipping_methods"], + }) + + expect(cart).toEqual( + expect.objectContaining({ + id: cart.id, + shipping_methods: [ + { + id: expect.any(String), + cart_id: cart.id, + description: null, + amount: 3000, + raw_amount: { + value: "3000", + precision: 20, + }, + metadata: null, + is_tax_inclusive: true, + name: "Test shipping option", + data: { test: "test" }, + shipping_option_id: shippingOption.id, + deleted_at: null, + updated_at: expect.any(Date), + created_at: expect.any(Date), + }, + ], + }) + ) + }) }) describe("listShippingOptionsForCartWorkflow", () => { diff --git a/packages/core/core-flows/src/cart/steps/validate-shipping-methods-data.ts b/packages/core/core-flows/src/cart/steps/validate-shipping-methods-data.ts new file mode 100644 index 0000000000000..eec94e9f8730e --- /dev/null +++ b/packages/core/core-flows/src/cart/steps/validate-shipping-methods-data.ts @@ -0,0 +1,50 @@ +import { Modules, promiseAll } from "@medusajs/framework/utils" +import { IFulfillmentModuleService } from "@medusajs/types" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" + +export interface ValidateShippingMethodsDataInput { + context: Record + options_to_validate: { + id: string + provider_id: string + option_data: Record + method_data: Record + }[] +} + +export const validateAndReturnShippingMethodsDataStepId = + "validate-and-return-shipping-methods-data" +/** + * This step validates shipping options to ensure they can be applied on a cart. + */ +export const validateAndReturnShippingMethodsDataStep = createStep( + validateAndReturnShippingMethodsDataStepId, + async (data: ValidateShippingMethodsDataInput, { container }) => { + const { options_to_validate = [] } = data + + if (!options_to_validate.length) { + return new StepResponse(void 0) + } + + const fulfillmentModule = container.resolve( + Modules.FULFILLMENT + ) + + const validatedData = await promiseAll( + options_to_validate.map(async (option) => { + const validated = await fulfillmentModule.validateFulfillmentData( + option.provider_id, + option.option_data, + option.method_data, + data.context + ) + + return { + [option.id]: validated, + } + }) + ) + + return new StepResponse(validatedData) + } +) diff --git a/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts b/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts index 49c519f18f0fc..5f7519bd735d6 100644 --- a/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts @@ -12,6 +12,7 @@ import { validateCartShippingOptionsStep, } from "../steps" import { validateCartStep } from "../steps/validate-cart" +import { validateAndReturnShippingMethodsDataStep } from "../steps/validate-shipping-methods-data" import { cartFieldsForRefreshSteps } from "../utils/fields" import { updateCartPromotionsWorkflow } from "./update-cart-promotions" import { updateTaxLinesWorkflow } from "./update-tax-lines" @@ -28,7 +29,7 @@ export const addShippingMethodToCartWorkflowId = "add-shipping-method-to-cart" /** * This workflow adds shipping methods to a cart. */ -export const addShippingMethodToWorkflow = createWorkflow( +export const addShippingMethodToCartWorkflow = createWorkflow( addShippingMethodToCartWorkflowId, ( input: WorkflowData @@ -59,6 +60,7 @@ export const addShippingMethodToWorkflow = createWorkflow( "name", "calculated_price.calculated_amount", "calculated_price.is_calculated_price_tax_inclusive", + "provider_id", ], variables: { id: optionIds, @@ -68,8 +70,30 @@ export const addShippingMethodToWorkflow = createWorkflow( }, }).config({ name: "fetch-shipping-option" }) - const shippingMethodInput = transform( + const validateShippingMethodsDataInput = transform( { input, shippingOptions }, + (data) => { + return data.input.options.map((inputOption) => { + const shippingOption = data.shippingOptions.find( + (so) => so.id === inputOption.id + ) + return { + id: inputOption.id, + provider_id: shippingOption?.provider_id, + option_data: shippingOption?.data ?? {}, + method_data: inputOption.data ?? {}, + } + }) + } + ) + + const validatedMethodData = validateAndReturnShippingMethodsDataStep({ + options_to_validate: validateShippingMethodsDataInput, + context: {}, // TODO: Add cart, when we have a better idea about what's appropriate to pass + }) + + const shippingMethodInput = transform( + { input, shippingOptions, validatedMethodData }, (data) => { const options = (data.input.options ?? []).map((option) => { const shippingOption = data.shippingOptions.find( @@ -83,13 +107,17 @@ export const addShippingMethodToWorkflow = createWorkflow( ) } + const methodData = data.validatedMethodData?.find((methodData) => { + return methodData?.[option.id] + }) + return { shipping_option_id: shippingOption.id, amount: shippingOption.calculated_price.calculated_amount, is_tax_inclusive: !!shippingOption.calculated_price .is_calculated_price_tax_inclusive, - data: option.data ?? {}, + data: methodData?.[option.id] ?? {}, name: shippingOption.name, cart_id: data.input.cart_id, } diff --git a/packages/core/types/src/fulfillment/service.ts b/packages/core/types/src/fulfillment/service.ts index 736435194d74f..a0642822ebdab 100644 --- a/packages/core/types/src/fulfillment/service.ts +++ b/packages/core/types/src/fulfillment/service.ts @@ -2572,6 +2572,38 @@ export interface IFulfillmentModuleService extends IModuleService { data: Record ): Promise + /** + * This method validates fulfillment data with the provider it belongs to. + * e.g. if the shipping option requires a drop point, the data you pass to create the + * shipping method must contain a drop point ID. This method can be used to + * validate that. + * + * @param {string} providerId - The fulfillment provider's ID. + * @param {Record} optionData - The fulfillment option data to validate. + * @param {Record} data - The fulfillment data to validate. + * @param {Record} context - The context to validate the fulfillment option data in. + * @returns {Promise} Whether the fulfillment option data is valid with the specified provider. + * + * @example + * const isValid = + * await fulfillmentModuleService.validateFulfillmentData( + * "webshipper", + * { + * requires_drop_point: true, + * }, + * { + * drop_point_id: "dp_123", + * }, + * {} + * ) + */ + validateFulfillmentData( + providerId: string, + optionData: Record, + data: Record, + context: Record + ): Promise> + /** * This method checks whether a shipping option can be used for a specified context. * diff --git a/packages/core/utils/src/fulfillment/provider.ts b/packages/core/utils/src/fulfillment/provider.ts index 0bda8570119a7..53715f6a05043 100644 --- a/packages/core/utils/src/fulfillment/provider.ts +++ b/packages/core/utils/src/fulfillment/provider.ts @@ -79,14 +79,6 @@ export class AbstractFulfillmentProviderService return obj?.constructor?._isFulfillmentService } - /** - * @ignore - * - * @privateRemarks - * This method is ignored as {@link validateOption} is the one used by the Fulfillment Module. - */ - static validateOptions(options: Record): void | never {} - /** * @ignore */ @@ -154,7 +146,11 @@ export class AbstractFulfillmentProviderService * } * } */ - async validateFulfillmentData(optionData, data, context): Promise { + async validateFulfillmentData( + optionData: Record, + data: Record, + context: Record + ): Promise { throw Error("validateFulfillmentData must be overridden by the child class") } @@ -175,7 +171,7 @@ export class AbstractFulfillmentProviderService * } * } */ - async validateOption(data): Promise { + async validateOption(data: Record): Promise { throw Error("validateOption must be overridden by the child class") } @@ -194,7 +190,7 @@ export class AbstractFulfillmentProviderService * } * } */ - async canCalculate(data): Promise { + async canCalculate(data: Record): Promise { throw Error("canCalculate must be overridden by the child class") } @@ -221,7 +217,11 @@ export class AbstractFulfillmentProviderService * } * } */ - async calculatePrice(optionData, data, cart): Promise { + async calculatePrice( + optionData: Record, + data: Record, + context: Record + ): Promise { throw Error("calculatePrice must be overridden by the child class") } @@ -265,7 +265,12 @@ export class AbstractFulfillmentProviderService * } * } */ - async createFulfillment(data, items, order, fulfillment): Promise { + async createFulfillment( + data: object, + items: object[], + order: object | undefined, + fulfillment: Record + ): Promise { throw Error("createFulfillment must be overridden by the child class") } @@ -285,7 +290,7 @@ export class AbstractFulfillmentProviderService * } * } */ - async cancelFulfillment(fulfillment): Promise { + async cancelFulfillment(fulfillment: Record): Promise { throw Error("cancelFulfillment must be overridden by the child class") } @@ -305,7 +310,7 @@ export class AbstractFulfillmentProviderService * } * } */ - async getFulfillmentDocuments(data) { + async getFulfillmentDocuments(data: Record) { return [] } @@ -340,7 +345,7 @@ export class AbstractFulfillmentProviderService * } * } */ - async createReturnFulfillment(fulfillment): Promise { + async createReturnFulfillment(fulfillment: Record): Promise { throw Error("createReturn must be overridden by the child class") } @@ -360,7 +365,7 @@ export class AbstractFulfillmentProviderService * } * } */ - async getReturnDocuments(data) { + async getReturnDocuments(data: Record) { return [] } @@ -381,7 +386,7 @@ export class AbstractFulfillmentProviderService * } * */ - async getShipmentDocuments(data) { + async getShipmentDocuments(data: Record) { return [] } @@ -408,7 +413,10 @@ export class AbstractFulfillmentProviderService * } * } */ - async retrieveDocuments(fulfillmentData, documentType) { + async retrieveDocuments( + fulfillmentData: Record, + documentType: string + ) { throw Error("retrieveDocuments must be overridden by the child class") } } diff --git a/packages/medusa/src/api/store/carts/[id]/shipping-methods/route.ts b/packages/medusa/src/api/store/carts/[id]/shipping-methods/route.ts index df1f24f289c27..0a2a6899d8d06 100644 --- a/packages/medusa/src/api/store/carts/[id]/shipping-methods/route.ts +++ b/packages/medusa/src/api/store/carts/[id]/shipping-methods/route.ts @@ -1,17 +1,16 @@ -import { addShippingMethodToWorkflow } from "@medusajs/core-flows" +import { addShippingMethodToCartWorkflow } from "@medusajs/core-flows" import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { HttpTypes } from "@medusajs/framework/types" import { refetchCart } from "../../helpers" import { StoreAddCartShippingMethodsType } from "../../validators" -import { HttpTypes } from "@medusajs/framework/types" export const POST = async ( req: MedusaRequest, res: MedusaResponse ) => { - const workflow = addShippingMethodToWorkflow(req.scope) const payload = req.validatedBody - await workflow.run({ + await addShippingMethodToCartWorkflow(req.scope).run({ input: { options: [{ id: payload.option_id, data: payload.data }], cart_id: req.params.id, diff --git a/packages/modules/fulfillment/src/services/fulfillment-module-service.ts b/packages/modules/fulfillment/src/services/fulfillment-module-service.ts index 1d09848f6c308..5fe4f9ecfd749 100644 --- a/packages/modules/fulfillment/src/services/fulfillment-module-service.ts +++ b/packages/modules/fulfillment/src/services/fulfillment-module-service.ts @@ -1927,6 +1927,20 @@ export default class FulfillmentModuleService ) } + async validateFulfillmentData( + providerId: string, + optionData: Record, + data: Record, + context: Record + ): Promise> { + return await this.fulfillmentProviderService_.validateFulfillmentData( + providerId, + optionData, + data, + context + ) + } + async validateFulfillmentOption( providerId: string, data: Record