diff --git a/.changeset/thick-bats-rescue.md b/.changeset/thick-bats-rescue.md new file mode 100644 index 0000000000..eaed1c9215 --- /dev/null +++ b/.changeset/thick-bats-rescue.md @@ -0,0 +1,6 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +--- + +feat(core-flows, medusa): add shipping methods to cart API 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 8ca136f7b1..677feb7d75 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -26,7 +26,7 @@ import { ISalesChannelModuleService, IStockLocationServiceNext, } from "@medusajs/types" -import { ContainerRegistrationKeys } from "@medusajs/utils" +import { ContainerRegistrationKeys, RuleOperator } from "@medusajs/utils" import { medusaIntegrationTestRunner } from "medusa-test-utils" import adminSeeder from "../../../../helpers/admin-seeder" @@ -1397,34 +1397,44 @@ medusaIntegrationTestRunner({ }) }) }) + describe("AddShippingMethodToCartWorkflow", () => { - it("should add shipping method to cart", async () => { - let cart = await cartModuleService.create({ + let cart + let shippingProfile + let fulfillmentSet + let priceSet + + beforeEach(async () => { + cart = await cartModuleService.create({ currency_code: "usd", + shipping_address: { + country_code: "us", + province: "ny", + }, }) - const shippingProfile = - await fulfillmentModule.createShippingProfiles({ - name: "Test", - type: "default", - }) + shippingProfile = await fulfillmentModule.createShippingProfiles({ + name: "Test", + type: "default", + }) - const fulfillmentSet = await fulfillmentModule.create({ + fulfillmentSet = await fulfillmentModule.create({ name: "Test", type: "test-type", service_zones: [ { name: "Test", - geo_zones: [ - { - type: "country", - country_code: "us", - }, - ], + geo_zones: [{ type: "country", country_code: "us" }], }, ], }) + priceSet = await pricingModule.create({ + prices: [{ amount: 3000, currency_code: "usd" }], + }) + }) + + it("should add shipping method to cart", async () => { const shippingOption = await fulfillmentModule.createShippingOptions({ name: "Test shipping option", service_zone_id: fulfillmentSet.service_zones[0].id, @@ -1436,41 +1446,26 @@ medusaIntegrationTestRunner({ description: "Test description", code: "test-code", }, - }) - - const priceSet = await pricingModule.create({ - prices: [ + rules: [ { - amount: 3000, - currency_code: "usd", + operator: RuleOperator.EQ, + attribute: "shipping_address.province", + value: "ny", }, ], }) await remoteLink.create([ { - [Modules.FULFILLMENT]: { - shipping_option_id: shippingOption.id, - }, - [Modules.PRICING]: { - price_set_id: priceSet.id, - }, + [Modules.FULFILLMENT]: { shipping_option_id: shippingOption.id }, + [Modules.PRICING]: { price_set_id: priceSet.id }, }, ]) - cart = await cartModuleService.retrieve(cart.id, { - select: ["id", "region_id", "currency_code"], - }) - await addShippingMethodToWorkflow(appContainer).run({ input: { - options: [ - { - id: shippingOption.id, - }, - ], + options: [{ id: shippingOption.id }], cart_id: cart.id, - currency_code: cart.currency_code, }, }) @@ -1491,6 +1486,77 @@ medusaIntegrationTestRunner({ }) ) }) + + it("should throw error when shipping option is not valid", async () => { + 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: "shipping_address.city", + value: "sf", + }, + ], + }) + + await remoteLink.create([ + { + [Modules.FULFILLMENT]: { shipping_option_id: shippingOption.id }, + [Modules.PRICING]: { price_set_id: priceSet.id }, + }, + ]) + + const { errors } = await addShippingMethodToWorkflow( + appContainer + ).run({ + input: { + options: [{ id: shippingOption.id }], + cart_id: cart.id, + }, + throwOnError: false, + }) + + // Rules are setup only for Germany, this should throw an error + expect(errors).toEqual([ + expect.objectContaining({ + error: expect.objectContaining({ + message: `Shipping Options are invalid for cart.`, + type: "invalid_data", + }), + }), + ]) + }) + + it("should throw error when shipping option is not present in the db", async () => { + const { errors } = await addShippingMethodToWorkflow( + appContainer + ).run({ + input: { + options: [{ id: "does-not-exist" }], + cart_id: cart.id, + }, + throwOnError: false, + }) + + // Rules are setup only for Berlin, this should throw an error + expect(errors).toEqual([ + expect.objectContaining({ + error: expect.objectContaining({ + message: "Shipping Options are invalid for cart.", + type: "invalid_data", + }), + }), + ]) + }) }) describe("listShippingOptionsForCartWorkflow", () => { diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index c28242c866..4b3936b32d 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -7,6 +7,7 @@ import { import { ICartModuleService, ICustomerModuleService, + IFulfillmentModuleService, IPricingModuleService, IProductModuleService, IPromotionModuleService, @@ -14,7 +15,11 @@ import { ISalesChannelModuleService, ITaxModuleService, } from "@medusajs/types" -import { PromotionRuleOperator, PromotionType } from "@medusajs/utils" +import { + PromotionRuleOperator, + PromotionType, + RuleOperator, +} from "@medusajs/utils" import { medusaIntegrationTestRunner } from "medusa-test-utils" import adminSeeder from "../../../../helpers/admin-seeder" import { createAuthenticatedCustomer } from "../../../helpers/create-authenticated-customer" @@ -38,6 +43,7 @@ medusaIntegrationTestRunner({ let remoteLink: RemoteLink let promotionModule: IPromotionModuleService let taxModule: ITaxModuleService + let fulfillmentModule: IFulfillmentModuleService let defaultRegion @@ -52,6 +58,9 @@ medusaIntegrationTestRunner({ remoteLink = appContainer.resolve(LinkModuleUtils.REMOTE_LINK) promotionModule = appContainer.resolve(ModuleRegistrationName.PROMOTION) taxModule = appContainer.resolve(ModuleRegistrationName.TAX) + fulfillmentModule = appContainer.resolve( + ModuleRegistrationName.FULFILLMENT + ) }) beforeEach(async () => { @@ -548,7 +557,7 @@ medusaIntegrationTestRunner({ await setupTaxStructure(taxModule) const region = await regionModule.create({ - name: "US", + name: "us", currency_code: "usd", }) @@ -562,9 +571,9 @@ medusaIntegrationTestRunner({ shipping_address: { address_1: "test address 1", address_2: "test address 2", - city: "NY", - country_code: "US", - province: "NY", + city: "ny", + country_code: "us", + province: "ny", postal_code: "94016", }, items: [ @@ -578,11 +587,78 @@ medusaIntegrationTestRunner({ ], }) + const shippingProfile = + await fulfillmentModule.createShippingProfiles({ + name: "Test", + type: "default", + }) + + const fulfillmentSet = await fulfillmentModule.create({ + name: "Test", + type: "test-type", + service_zones: [ + { + name: "Test", + geo_zones: [{ type: "country", country_code: "us" }], + }, + ], + }) + + 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: "customer.email", + value: "tony@stark.com", + }, + ], + }) + + const shippingOption2 = 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: "customer.email", + value: "tony@stark.com", + }, + ], + } + ) + // Manually inserting shipping methods here since the cart does not // currently support it. Move to API when ready. await cartModule.addShippingMethods(cart.id, [ - { amount: 500, name: "express" }, - { amount: 500, name: "standard" }, + { + amount: 500, + name: "express", + shipping_option_id: shippingOption.id, + }, + { + amount: 500, + name: "standard", + shipping_option_id: shippingOption2.id, + }, ]) let updated = await api.post(`/store/carts/${cart.id}`, { @@ -590,7 +666,10 @@ medusaIntegrationTestRunner({ email: "tony@stark.com", sales_channel_id: salesChannel.id, }) - + console.log( + "updated.data.cart --- ", + JSON.stringify(updated.data.cart, null, 4) + ) expect(updated.status).toEqual(200) expect(updated.data.cart).toEqual( expect.objectContaining({ @@ -606,13 +685,13 @@ medusaIntegrationTestRunner({ }), sales_channel_id: salesChannel.id, shipping_address: expect.objectContaining({ - city: "NY", - country_code: "US", - province: "NY", + city: "ny", + country_code: "us", + province: "ny", }), shipping_methods: expect.arrayContaining([ expect.objectContaining({ - shipping_option_id: null, + shipping_option_id: shippingOption2.id, amount: 500, tax_lines: [ expect.objectContaining({ @@ -625,7 +704,7 @@ medusaIntegrationTestRunner({ adjustments: [], }), expect.objectContaining({ - shipping_option_id: null, + shipping_option_id: shippingOption.id, amount: 500, tax_lines: [ expect.objectContaining({ @@ -685,6 +764,140 @@ medusaIntegrationTestRunner({ }) ) }) + + it("should remove invalid shipping methods", async () => { + await setupTaxStructure(taxModule) + + const region = await regionModule.create({ + name: "US", + currency_code: "usd", + }) + + const cart = await cartModule.create({ + region_id: region.id, + currency_code: "eur", + email: "tony@stark.com", + shipping_address: { + address_1: "test address 1", + address_2: "test address 2", + city: "ny", + country_code: "us", + province: "ny", + postal_code: "94016", + }, + }) + + const shippingProfile = + await fulfillmentModule.createShippingProfiles({ + name: "Test", + type: "default", + }) + + const fulfillmentSet = await fulfillmentModule.create({ + name: "Test", + type: "test-type", + service_zones: [ + { + name: "Test", + geo_zones: [{ type: "country", country_code: "us" }], + }, + ], + }) + + const shippingOptionOldValid = + 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: "customer.email", + value: "tony@stark.com", + }, + ], + }) + + const shippingOptionNewValid = + await fulfillmentModule.createShippingOptions({ + name: "Test shipping option new", + 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: "customer.email", + value: "jon@stark.com", + }, + ], + }) + + await cartModule.addShippingMethods(cart.id, [ + // should be removed + { + amount: 500, + name: "express", + shipping_option_id: shippingOptionOldValid.id, + }, + // should be kept + { + amount: 500, + name: "express-new", + shipping_option_id: shippingOptionNewValid.id, + }, + // should be removed + { + amount: 500, + name: "standard", + shipping_option_id: "does-not-exist", + }, + ]) + + let updated = await api.post(`/store/carts/${cart.id}`, { + email: "jon@stark.com", + }) + + expect(updated.status).toEqual(200) + expect(updated.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + email: "jon@stark.com", + shipping_methods: [ + expect.objectContaining({ + shipping_option_id: shippingOptionNewValid.id, + }), + ], + }) + ) + + updated = await api.post(`/store/carts/${cart.id}`, { + email: null, + sales_channel_id: null, + }) + + expect(updated.status).toEqual(200) + expect(updated.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + email: null, + shipping_methods: [], + }) + ) + }) }) describe("POST /store/carts/:id", () => { @@ -1100,6 +1313,85 @@ medusaIntegrationTestRunner({ }) }) }) + + describe("POST /store/carts/:id/shipping-methods", () => { + it("should add a shipping methods to a cart", async () => { + const cart = await cartModule.create({ + currency_code: "usd", + shipping_address: { country_code: "us" }, + items: [], + }) + + const shippingProfile = + await fulfillmentModule.createShippingProfiles({ + name: "Test", + type: "default", + }) + + const fulfillmentSet = await fulfillmentModule.create({ + name: "Test", + type: "test-type", + service_zones: [ + { + name: "Test", + geo_zones: [{ type: "country", country_code: "us" }], + }, + ], + }) + + const priceSet = await pricingModule.create({ + prices: [{ amount: 3000, currency_code: "usd" }], + }) + + 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: "shipping_address.country_code", + value: "us", + }, + ], + }) + + await remoteLink.create([ + { + [Modules.FULFILLMENT]: { shipping_option_id: shippingOption.id }, + [Modules.PRICING]: { price_set_id: priceSet.id }, + }, + ]) + + let response = await api.post( + `/store/carts/${cart.id}/shipping-methods`, + { option_id: shippingOption.id } + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + shipping_methods: [ + { + shipping_option_id: shippingOption.id, + amount: 3000, + id: expect.any(String), + tax_lines: [], + adjustments: [], + }, + ], + }) + ) + }) + }) }) }, }) diff --git a/packages/core-flows/src/common/steps/use-remote-query.ts b/packages/core-flows/src/common/steps/use-remote-query.ts index ad4ab203b4..3302ae0549 100644 --- a/packages/core-flows/src/common/steps/use-remote-query.ts +++ b/packages/core-flows/src/common/steps/use-remote-query.ts @@ -5,21 +5,24 @@ interface StepInput { entry_point: string fields: string[] variables?: Record + list?: boolean } export const useRemoteQueryStepId = "use-remote-query" export const useRemoteQueryStep = createStep( useRemoteQueryStepId, async (data: StepInput, { container }) => { + const { list = true, fields, variables, entry_point: entryPoint } = data const query = container.resolve("remoteQuery") const queryObject = remoteQueryObjectFromString({ - entryPoint: data.entry_point, - fields: data.fields, - variables: data.variables, + entryPoint, + fields, + variables, }) - const result = await query(queryObject) + const entities = await query(queryObject) + const result = list ? entities : entities[0] return new StepResponse(result) } diff --git a/packages/core-flows/src/definition/cart/steps/index.ts b/packages/core-flows/src/definition/cart/steps/index.ts index 2ae250b5eb..8a1ea186f4 100644 --- a/packages/core-flows/src/definition/cart/steps/index.ts +++ b/packages/core-flows/src/definition/cart/steps/index.ts @@ -13,6 +13,7 @@ export * from "./get-shipping-option-price-sets" export * from "./get-variant-price-sets" export * from "./get-variants" export * from "./prepare-adjustments-from-promotion-actions" +export * from "./refresh-cart-shipping-methods" export * from "./remove-line-item-adjustments" export * from "./remove-shipping-method-adjustments" export * from "./retrieve-cart" @@ -20,5 +21,5 @@ export * from "./retrieve-cart-with-links" export * from "./set-tax-lines-for-items" export * from "./update-cart-promotions" export * from "./update-carts" +export * from "./validate-cart-shipping-options" export * from "./validate-variants-existence" - diff --git a/packages/core-flows/src/definition/cart/steps/refresh-cart-shipping-methods.ts b/packages/core-flows/src/definition/cart/steps/refresh-cart-shipping-methods.ts new file mode 100644 index 0000000000..d8f389294c --- /dev/null +++ b/packages/core-flows/src/definition/cart/steps/refresh-cart-shipping-methods.ts @@ -0,0 +1,72 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { CartDTO, ICartModuleService } from "@medusajs/types" +import { arrayDifference } from "@medusajs/utils" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" +import { IFulfillmentModuleService } from "../../../../../types/dist/fulfillment/service" + +interface StepInput { + cart: CartDTO +} + +export const refreshCartShippingMethodsStepId = "refresh-cart-shipping-methods" +export const refreshCartShippingMethodsStep = createStep( + refreshCartShippingMethodsStepId, + async (data: StepInput, { container }) => { + const { cart } = data + const { shipping_methods: shippingMethods = [] } = cart + + if (!shippingMethods?.length) { + return new StepResponse(void 0, []) + } + + const fulfillmentModule = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + const cartModule = container.resolve( + ModuleRegistrationName.CART + ) + + const shippingOptionIds: string[] = shippingMethods.map( + (sm) => sm.shipping_option_id! + ) + + const validShippingOptions = + await fulfillmentModule.listShippingOptionsForContext( + { + id: shippingOptionIds, + context: { ...cart }, + address: { + country_code: cart.shipping_address?.country_code, + province_code: cart.shipping_address?.province, + city: cart.shipping_address?.city, + postal_expression: cart.shipping_address?.postal_code, + }, + }, + { relations: ["rules"] } + ) + + const validShippingOptionIds = validShippingOptions.map((o) => o.id) + const invalidShippingOptionIds = arrayDifference( + shippingOptionIds, + validShippingOptionIds + ) + + const shippingMethodsToDelete = shippingMethods + .filter((sm) => invalidShippingOptionIds.includes(sm.shipping_option_id!)) + .map((sm) => sm.id) + + await cartModule.softDeleteShippingMethods(shippingMethodsToDelete) + + return new StepResponse(void 0, shippingMethodsToDelete) + }, + async (shippingMethodsToRestore, { container }) => { + if (shippingMethodsToRestore?.length) { + const cartModule = container.resolve( + ModuleRegistrationName.CART + ) + + await cartModule.restoreShippingMethods(shippingMethodsToRestore) + } + } +) diff --git a/packages/core-flows/src/definition/cart/steps/validate-cart-shipping-options.ts b/packages/core-flows/src/definition/cart/steps/validate-cart-shipping-options.ts new file mode 100644 index 0000000000..80ad20af22 --- /dev/null +++ b/packages/core-flows/src/definition/cart/steps/validate-cart-shipping-options.ts @@ -0,0 +1,54 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { CartDTO } from "@medusajs/types" +import { arrayDifference, MedusaError } from "@medusajs/utils" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" +import { IFulfillmentModuleService } from "../../../../../types/dist/fulfillment/service" + +interface StepInput { + cart: CartDTO + option_ids: string[] +} + +export const validateCartShippingOptionsStepId = + "validate-cart-shipping-options" +export const validateCartShippingOptionsStep = createStep( + validateCartShippingOptionsStepId, + async (data: StepInput, { container }) => { + const { option_ids: optionIds = [], cart } = data + + if (!optionIds.length) { + return new StepResponse(void 0) + } + + const fulfillmentModule = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + const validShippingOptions = + await fulfillmentModule.listShippingOptionsForContext( + { + id: optionIds, + context: { ...cart }, + address: { + country_code: cart.shipping_address?.country_code, + province_code: cart.shipping_address?.province, + city: cart.shipping_address?.city, + postal_expression: cart.shipping_address?.postal_code, + }, + }, + { relations: ["rules"] } + ) + + const validShippingOptionIds = validShippingOptions.map((o) => o.id) + const invalidOptionIds = arrayDifference(optionIds, validShippingOptionIds) + + if (invalidOptionIds.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Shipping Options are invalid for cart.` + ) + } + + return new StepResponse(void 0) + } +) diff --git a/packages/core-flows/src/definition/cart/utils/fields.ts b/packages/core-flows/src/definition/cart/utils/fields.ts new file mode 100644 index 0000000000..e213d83bfd --- /dev/null +++ b/packages/core-flows/src/definition/cart/utils/fields.ts @@ -0,0 +1,12 @@ +export const cartFieldsForRefreshSteps = [ + "region_id", + "currency_code", + "region.*", + "items.*", + "items.tax_lines.*", + "shipping_address.*", + "shipping_methods.*", + "shipping_methods.tax_lines*", + "customer.*", + "customer.groups.*", +] diff --git a/packages/core-flows/src/definition/cart/workflows/add-shipping-method-to-cart.ts b/packages/core-flows/src/definition/cart/workflows/add-shipping-method-to-cart.ts index 2da7c0b111..192d22ac99 100644 --- a/packages/core-flows/src/definition/cart/workflows/add-shipping-method-to-cart.ts +++ b/packages/core-flows/src/definition/cart/workflows/add-shipping-method-to-cart.ts @@ -4,13 +4,16 @@ import { transform, } from "@medusajs/workflows-sdk" import { useRemoteQueryStep } from "../../../common/steps/use-remote-query" -import { addShippingMethodToCartStep } from "../steps" +import { + addShippingMethodToCartStep, + validateCartShippingOptionsStep, +} from "../steps" import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions" import { updateTaxLinesStep } from "../steps/update-tax-lines" +import { cartFieldsForRefreshSteps } from "../utils/fields" interface AddShippingMethodToCartWorkflowInput { cart_id: string - currency_code: string options: { id: string data?: Record @@ -23,22 +26,32 @@ export const addShippingMethodToWorkflow = createWorkflow( ( input: WorkflowData ): WorkflowData => { + const cart = useRemoteQueryStep({ + entry_point: "cart", + fields: cartFieldsForRefreshSteps, + variables: { id: input.cart_id }, + list: false, + }) + const optionIds = transform({ input }, (data) => { return (data.input.options ?? []).map((i) => i.id) }) + validateCartShippingOptionsStep({ + option_ids: optionIds, + cart, + }) + const shippingOptions = useRemoteQueryStep({ entry_point: "shipping_option", fields: ["id", "name", "calculated_price.calculated_amount"], variables: { id: optionIds, calculated_price: { - context: { - currency_code: input.currency_code, - }, + context: { currency_code: cart.currency_code }, }, }, - }) + }).config({ name: "fetch-shipping-option" }) const shippingMethodInput = transform( { input, shippingOptions }, diff --git a/packages/core-flows/src/definition/cart/workflows/add-to-cart.ts b/packages/core-flows/src/definition/cart/workflows/add-to-cart.ts index a4b0dcb7ca..a16fe36405 100644 --- a/packages/core-flows/src/definition/cart/workflows/add-to-cart.ts +++ b/packages/core-flows/src/definition/cart/workflows/add-to-cart.ts @@ -14,10 +14,12 @@ import { confirmInventoryStep, getVariantPriceSetsStep, getVariantsStep, + refreshCartShippingMethodsStep, validateVariantsExistStep, } from "../steps" import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions" import { updateTaxLinesStep } from "../steps/update-tax-lines" +import { cartFieldsForRefreshSteps } from "../utils/fields" import { prepareConfirmInventoryInput } from "../utils/prepare-confirm-inventory-input" import { prepareLineItemData } from "../utils/prepare-line-item-data" import { refreshPaymentCollectionForCartStep } from "./refresh-payment-collection" @@ -128,15 +130,19 @@ export const addToCartWorkflow = createWorkflow( const items = addToCartStep({ items: lineItems }) - updateTaxLinesStep({ - cart_or_cart_id: input.cart, - items, - }) + const cart = useRemoteQueryStep({ + entry_point: "cart", + fields: cartFieldsForRefreshSteps, + variables: { id: input.cart.id }, + list: false, + }).config({ name: "refetch–cart" }) + refreshCartShippingMethodsStep({ cart }) + // TODO: since refreshCartShippingMethodsStep potentially removes cart shipping methods, we need the updated cart here + // for the following 2 steps as they act upon final cart shape + updateTaxLinesStep({ cart_or_cart_id: cart, items }) refreshCartPromotionsStep({ id: input.cart.id }) - refreshPaymentCollectionForCartStep({ - cart_id: input.cart.id, - }) + refreshPaymentCollectionForCartStep({ cart_id: input.cart.id }) return items } diff --git a/packages/core-flows/src/definition/cart/workflows/update-cart.ts b/packages/core-flows/src/definition/cart/workflows/update-cart.ts index 51649d8eea..f6db99a12f 100644 --- a/packages/core-flows/src/definition/cart/workflows/update-cart.ts +++ b/packages/core-flows/src/definition/cart/workflows/update-cart.ts @@ -6,14 +6,17 @@ import { parallelize, transform, } from "@medusajs/workflows-sdk" +import { useRemoteQueryStep } from "../../../common" import { findOneOrAnyRegionStep, findOrCreateCustomerStep, findSalesChannelStep, + refreshCartShippingMethodsStep, updateCartsStep, } from "../steps" import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions" import { updateTaxLinesStep } from "../steps/update-tax-lines" +import { cartFieldsForRefreshSteps } from "../utils/fields" import { refreshPaymentCollectionForCartStep } from "./refresh-payment-collection" export const updateCartWorkflowId = "update-cart" @@ -63,6 +66,14 @@ export const updateCartWorkflow = createWorkflow( const carts = updateCartsStep([cartInput]) + const cart = useRemoteQueryStep({ + entry_point: "cart", + fields: cartFieldsForRefreshSteps, + variables: { id: cartInput.id }, + list: false, + }).config({ name: "refetch–cart" }) + + refreshCartShippingMethodsStep({ cart }) updateTaxLinesStep({ cart_or_cart_id: carts[0].id }) refreshCartPromotionsStep({ id: input.id, diff --git a/packages/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts b/packages/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts index b26324adc4..febda3b7e9 100644 --- a/packages/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts +++ b/packages/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts @@ -5,14 +5,16 @@ import { transform, } from "@medusajs/workflows-sdk" import { MedusaError } from "medusa-core-utils" +import { useRemoteQueryStep } from "../../../common/steps/use-remote-query" +import { updateLineItemsStep } from "../../line-item/steps" import { confirmInventoryStep, getVariantPriceSetsStep, getVariantsStep, -} from ".." -import { useRemoteQueryStep } from "../../../common/steps/use-remote-query" -import { updateLineItemsStep } from "../../line-item/steps" + refreshCartShippingMethodsStep, +} from "../steps" import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions" +import { cartFieldsForRefreshSteps } from "../utils/fields" import { prepareConfirmInventoryInput } from "../utils/prepare-confirm-inventory-input" import { refreshPaymentCollectionForCartStep } from "./refresh-payment-collection" @@ -106,10 +108,16 @@ export const updateLineItemInCartWorkflow = createWorkflow( selector: lineItemUpdate.selector, }) + const cart = useRemoteQueryStep({ + entry_point: "cart", + fields: cartFieldsForRefreshSteps, + variables: { id: input.cart.id }, + list: false, + }).config({ name: "refetch–cart" }) + + refreshCartShippingMethodsStep({ cart }) refreshCartPromotionsStep({ id: input.cart.id }) - refreshPaymentCollectionForCartStep({ - cart_id: input.cart.id, - }) + refreshPaymentCollectionForCartStep({ cart_id: input.cart.id }) const updatedItem = transform({ result }, (data) => data.result?.[0]) diff --git a/packages/medusa/src/api-v2/store/carts/[id]/shipping-methods/route.ts b/packages/medusa/src/api-v2/store/carts/[id]/shipping-methods/route.ts new file mode 100644 index 0000000000..6414e38365 --- /dev/null +++ b/packages/medusa/src/api-v2/store/carts/[id]/shipping-methods/route.ts @@ -0,0 +1,32 @@ +import { addShippingMethodToWorkflow } from "@medusajs/core-flows" +import { MedusaRequest, MedusaResponse } from "../../../../../types/routing" +import { refetchCart } from "../../helpers" +import { StoreAddCartShippingMethodsType } from "../../validators" + +export const POST = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const workflow = addShippingMethodToWorkflow(req.scope) + const payload = req.validatedBody + + const { errors } = await workflow.run({ + input: { + options: [{ id: payload.option_id, data: payload.data }], + cart_id: req.params.id, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const cart = await refetchCart( + req.params.id, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ cart }) +} diff --git a/packages/medusa/src/api-v2/store/carts/middlewares.ts b/packages/medusa/src/api-v2/store/carts/middlewares.ts index 5e9fd36c34..7acd360b8c 100644 --- a/packages/medusa/src/api-v2/store/carts/middlewares.ts +++ b/packages/medusa/src/api-v2/store/carts/middlewares.ts @@ -6,6 +6,7 @@ import * as QueryConfig from "./query-config" import { StoreAddCartLineItem, StoreAddCartPromotions, + StoreAddCartShippingMethods, StoreCalculateCartTaxes, StoreCreateCart, StoreGetCartsCart, @@ -121,6 +122,17 @@ export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/store/carts/:id/shipping-methods", + middlewares: [ + validateAndTransformBody(StoreAddCartShippingMethods), + validateAndTransformQuery( + StoreGetCartsCart, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, { method: ["DELETE"], matcher: "/store/carts/:id/promotions", diff --git a/packages/medusa/src/api-v2/store/carts/validators.ts b/packages/medusa/src/api-v2/store/carts/validators.ts index 9d273a7d29..057ae0eb9f 100644 --- a/packages/medusa/src/api-v2/store/carts/validators.ts +++ b/packages/medusa/src/api-v2/store/carts/validators.ts @@ -72,3 +72,13 @@ export const StoreUpdateCartLineItem = z.object({ quantity: z.number(), metadata: z.record(z.unknown()).optional(), }) + +export type StoreAddCartShippingMethodsType = z.infer< + typeof StoreAddCartShippingMethods +> +export const StoreAddCartShippingMethods = z + .object({ + option_id: z.string(), + data: z.record(z.unknown()).optional(), + }) + .strict() diff --git a/packages/types/src/cart/common.ts b/packages/types/src/cart/common.ts index 5444704e84..52ce29e1f7 100644 --- a/packages/types/src/cart/common.ts +++ b/packages/types/src/cart/common.ts @@ -1145,7 +1145,7 @@ export interface FilterableShippingMethodProps /** * Filter the shipping methods by the ID of their associated shipping option. */ - shipping_option_id?: string | string[] + shipping_option_id?: string | string[] | OperatorMap } /** diff --git a/packages/types/src/cart/mutations.ts b/packages/types/src/cart/mutations.ts index 56f0acd82e..7dda0ff230 100644 --- a/packages/types/src/cart/mutations.ts +++ b/packages/types/src/cart/mutations.ts @@ -673,6 +673,11 @@ export interface CreateShippingMethodDTO { */ amount: BigNumberInput + /** + * The amount of the shipping method. + */ + shipping_option_id?: string + /** * The data of the shipping method. */ @@ -703,6 +708,11 @@ export interface CreateShippingMethodForSingleCartDTO { */ amount: BigNumberInput + /** + * The amount of the shipping method. + */ + shipping_option_id?: string + /** * The data of the shipping method. */ diff --git a/packages/types/src/cart/service.ts b/packages/types/src/cart/service.ts index 5989412ddb..7f0b9f251c 100644 --- a/packages/types/src/cart/service.ts +++ b/packages/types/src/cart/service.ts @@ -743,7 +743,7 @@ export interface ICartModuleService extends IModuleService { */ listShippingMethods( filters: FilterableShippingMethodProps, - config: FindConfig, + config?: FindConfig, sharedContext?: Context ): Promise diff --git a/packages/utils/src/common/array-difference.ts b/packages/utils/src/common/array-difference.ts index 5aef456e7e..913f514b81 100644 --- a/packages/utils/src/common/array-difference.ts +++ b/packages/utils/src/common/array-difference.ts @@ -1,9 +1,9 @@ type ArrayDifferenceElement = string | number -export function arrayDifference( - mainArray: ArrayDifferenceElement[], - differingArray: ArrayDifferenceElement[] -): ArrayDifferenceElement[] { +export function arrayDifference( + mainArray: TElement[], + differingArray: TElement[] +): TElement[] { const mainArraySet = new Set(mainArray) const differingArraySet = new Set(differingArray)