diff --git a/.changeset/funny-candles-bake.md b/.changeset/funny-candles-bake.md new file mode 100644 index 0000000000..dc7cd0e946 --- /dev/null +++ b/.changeset/funny-candles-bake.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa": patch +"@medusajs/orchestration": patch +--- + +feat: List shipping options for cart 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 c4a8d58916..14731c7a06 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -7,6 +7,7 @@ import { deleteLineItemsWorkflow, findOrCreateCustomerStepId, linkCartAndPaymentCollectionsStepId, + listShippingOptionsForCartWorkflow, refreshPaymentCollectionForCartWorkflow, updateLineItemInCartWorkflow, updateLineItemsStepId, @@ -22,7 +23,9 @@ import { IProductModuleService, IRegionModuleService, ISalesChannelModuleService, + IStockLocationServiceNext, } from "@medusajs/types" +import { ContainerRegistrationKeys } from "@medusajs/utils" import { medusaIntegrationTestRunner } from "medusa-test-utils" import adminSeeder from "../../../../helpers/admin-seeder" @@ -43,6 +46,7 @@ medusaIntegrationTestRunner({ let pricingModule: IPricingModuleService let paymentModule: IPaymentModuleService let fulfillmentModule: IFulfillmentModuleService + let locationModule: IStockLocationServiceNext let remoteLink, remoteQuery let defaultRegion @@ -63,8 +67,13 @@ medusaIntegrationTestRunner({ fulfillmentModule = appContainer.resolve( ModuleRegistrationName.FULFILLMENT ) - remoteLink = appContainer.resolve("remoteLink") - remoteQuery = appContainer.resolve("remoteQuery") + locationModule = appContainer.resolve( + ModuleRegistrationName.STOCK_LOCATION + ) + remoteLink = appContainer.resolve(ContainerRegistrationKeys.REMOTE_LINK) + remoteQuery = appContainer.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) }) beforeEach(async () => { @@ -672,12 +681,84 @@ medusaIntegrationTestRunner({ expect(updatedItem).not.toBeUndefined() }) }) + }) - describe("createPaymentCollectionForCart", () => { - it("should create a payment collection and link it to cart", async () => { - const cart = await cartModuleService.create({ - currency_code: "dkk", + describe("createPaymentCollectionForCart", () => { + it("should create a payment collection and link it to cart", async () => { + const cart = await cartModuleService.create({ + currency_code: "dkk", + region_id: defaultRegion.id, + items: [ + { + quantity: 1, + unit_price: 5000, + title: "Test item", + }, + ], + }) + + await createPaymentCollectionForCartWorkflow(appContainer).run({ + input: { + cart_id: cart.id, region_id: defaultRegion.id, + currency_code: "dkk", + amount: 5000, + }, + throwOnError: false, + }) + + const result = await remoteQuery( + { + cart: { + fields: ["id"], + payment_collection: { + fields: ["id", "amount", "currency_code"], + }, + }, + }, + { + cart: { + id: cart.id, + }, + } + ) + + expect(result).toEqual([ + expect.objectContaining({ + id: cart.id, + payment_collection: expect.objectContaining({ + amount: 5000, + currency_code: "dkk", + }), + }), + ]) + }) + + describe("compensation", () => { + it("should dismiss cart <> payment collection link and delete created payment collection", async () => { + const workflow = + createPaymentCollectionForCartWorkflow(appContainer) + + workflow.appendAction( + "throw", + linkCartAndPaymentCollectionsStepId, + { + invoke: async function failStep() { + throw new Error( + `Failed to do something after linking cart and payment collection` + ) + }, + } + ) + + const region = await regionModuleService.create({ + name: "US", + currency_code: "usd", + }) + + const cart = await cartModuleService.create({ + currency_code: "usd", + region_id: region.id, items: [ { quantity: 1, @@ -687,17 +768,27 @@ medusaIntegrationTestRunner({ ], }) - await createPaymentCollectionForCartWorkflow(appContainer).run({ + const { errors } = await workflow.run({ input: { cart_id: cart.id, - region_id: defaultRegion.id, - currency_code: "dkk", + region_id: region.id, + currency_code: "usd", amount: 5000, }, throwOnError: false, }) - const result = await remoteQuery( + expect(errors).toEqual([ + { + action: "throw", + handlerType: "invoke", + error: new Error( + `Failed to do something after linking cart and payment collection` + ), + }, + ]) + + const carts = await remoteQuery( { cart: { fields: ["id"], @@ -713,101 +804,19 @@ medusaIntegrationTestRunner({ } ) - expect(result).toEqual([ + const payCols = await remoteQuery({ + payment_collection: { + fields: ["id"], + }, + }) + + expect(carts).toEqual([ expect.objectContaining({ id: cart.id, - payment_collection: expect.objectContaining({ - amount: 5000, - currency_code: "dkk", - }), + payment_collection: undefined, }), ]) - }) - - describe("compensation", () => { - it("should dismiss cart <> payment collection link and delete created payment collection", async () => { - const workflow = - createPaymentCollectionForCartWorkflow(appContainer) - - workflow.appendAction( - "throw", - linkCartAndPaymentCollectionsStepId, - { - invoke: async function failStep() { - throw new Error( - `Failed to do something after linking cart and payment collection` - ) - }, - } - ) - - const region = await regionModuleService.create({ - name: "US", - currency_code: "usd", - }) - - const cart = await cartModuleService.create({ - currency_code: "usd", - region_id: region.id, - items: [ - { - quantity: 1, - unit_price: 5000, - title: "Test item", - }, - ], - }) - - const { errors } = await workflow.run({ - input: { - cart_id: cart.id, - region_id: region.id, - currency_code: "usd", - amount: 5000, - }, - throwOnError: false, - }) - - expect(errors).toEqual([ - { - action: "throw", - handlerType: "invoke", - error: new Error( - `Failed to do something after linking cart and payment collection` - ), - }, - ]) - - const carts = await remoteQuery( - { - cart: { - fields: ["id"], - payment_collection: { - fields: ["id", "amount", "currency_code"], - }, - }, - }, - { - cart: { - id: cart.id, - }, - } - ) - - const payCols = await remoteQuery({ - payment_collection: { - fields: ["id"], - }, - }) - - expect(carts).toEqual([ - expect.objectContaining({ - id: cart.id, - payment_collection: undefined, - }), - ]) - expect(payCols.length).toEqual(0) - }) + expect(payCols.length).toEqual(0) }) }) }) @@ -995,24 +1004,26 @@ medusaIntegrationTestRunner({ name: "Test", type: "default", }) + const fulfillmentSet = await fulfillmentModule.create({ name: "Test", type: "test-type", - }) - const serviceZone = await fulfillmentModule.createServiceZones({ - name: "Test", - fulfillment_set_id: fulfillmentSet.id, - geo_zones: [ + service_zones: [ { - type: "country", - country_code: "us", + name: "Test", + geo_zones: [ + { + type: "country", + country_code: "us", + }, + ], }, ], }) const shippingOption = await fulfillmentModule.createShippingOptions({ name: "Test shipping option", - service_zone_id: serviceZone.id, + service_zone_id: fulfillmentSet.service_zones[0].id, shipping_profile_id: shippingProfile.id, provider_id: "manual_test-provider", price_type: "flat", @@ -1077,6 +1088,337 @@ medusaIntegrationTestRunner({ ) }) }) + + describe("listShippingOptionsForCartWorkflow", () => { + it("should list shipping options for cart", async () => { + const salesChannel = await scModuleService.create({ + name: "Webshop", + }) + + const location = await locationModule.create({ + name: "Europe", + }) + + let cart = await cartModuleService.create({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + shipping_address: { + city: "CPH", + province: "Sjaelland", + country_code: "dk", + }, + }) + + 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", + }, + }) + + const priceSet = await pricingModule.create({ + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }) + + await remoteLink.create([ + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + { + [Modules.FULFILLMENT]: { + fulfillment_set_id: fulfillmentSet.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + { + [Modules.FULFILLMENT]: { + shipping_option_id: shippingOption.id, + }, + [Modules.PRICING]: { + price_set_id: priceSet.id, + }, + }, + ]) + + cart = await cartModuleService.retrieve(cart.id, { + select: ["id"], + relations: ["shipping_address"], + }) + + const { result } = await listShippingOptionsForCartWorkflow( + appContainer + ).run({ + input: { + cart_id: cart.id, + sales_channel_id: salesChannel.id, + currency_code: "usd", + shipping_address: { + city: cart.shipping_address?.city, + province: cart.shipping_address?.province, + country_code: cart.shipping_address?.country_code, + }, + }, + }) + + expect(result).toEqual([ + expect.objectContaining({ + amount: 3000, + name: "Test shipping option", + id: shippingOption.id, + }), + ]) + }) + + it("should list no shipping options for cart, if sales channel is not associated with location", async () => { + const salesChannel = await scModuleService.create({ + name: "Webshop", + }) + + const location = await locationModule.create({ + name: "Europe", + }) + + let cart = await cartModuleService.create({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + shipping_address: { + city: "CPH", + province: "Sjaelland", + country_code: "dk", + }, + }) + + 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", + }, + }) + + const priceSet = await pricingModule.create({ + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }) + + await remoteLink.create([ + { + [Modules.FULFILLMENT]: { + fulfillment_set_id: fulfillmentSet.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + { + [Modules.FULFILLMENT]: { + shipping_option_id: shippingOption.id, + }, + [Modules.PRICING]: { + price_set_id: priceSet.id, + }, + }, + ]) + + cart = await cartModuleService.retrieve(cart.id, { + select: ["id"], + relations: ["shipping_address"], + }) + + const { result } = await listShippingOptionsForCartWorkflow( + appContainer + ).run({ + input: { + cart_id: cart.id, + sales_channel_id: salesChannel.id, + currency_code: "usd", + shipping_address: { + city: cart.shipping_address?.city, + province: cart.shipping_address?.province, + country_code: cart.shipping_address?.country_code, + }, + }, + }) + + expect(result).toEqual([]) + }) + + it("should throw when shipping options are missing prices", async () => { + const salesChannel = await scModuleService.create({ + name: "Webshop", + }) + + const location = await locationModule.create({ + name: "Europe", + }) + + let cart = await cartModuleService.create({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + shipping_address: { + city: "CPH", + province: "Sjaelland", + country_code: "dk", + }, + }) + + 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", + }, + }) + + await remoteLink.create([ + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + { + [Modules.FULFILLMENT]: { + fulfillment_set_id: fulfillmentSet.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + ]) + + cart = await cartModuleService.retrieve(cart.id, { + select: ["id"], + relations: ["shipping_address"], + }) + + const { errors } = await listShippingOptionsForCartWorkflow( + appContainer + ).run({ + input: { + cart_id: cart.id, + sales_channel_id: salesChannel.id, + currency_code: "usd", + shipping_address: { + city: cart.shipping_address?.city, + province: cart.shipping_address?.province, + country_code: cart.shipping_address?.country_code, + }, + }, + throwOnError: false, + }) + + expect(errors).toEqual([ + { + action: "get-shipping-option-price-sets", + error: new Error( + `Shipping options with IDs ${shippingOption.id} do not have a price` + ), + handlerType: "invoke", + }, + ]) + }) + }) }) }, }) diff --git a/integration-tests/modules/__tests__/link-modules/fulfillment-set-location.spec.ts b/integration-tests/modules/__tests__/link-modules/fulfillment-set-location.spec.ts new file mode 100644 index 0000000000..1033947f3d --- /dev/null +++ b/integration-tests/modules/__tests__/link-modules/fulfillment-set-location.spec.ts @@ -0,0 +1,87 @@ +import { ModuleRegistrationName, Modules } from "@medusajs/modules-sdk" +import { + IFulfillmentModuleService, + ISalesChannelModuleService, + IStockLocationServiceNext, +} from "@medusajs/types" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { medusaIntegrationTestRunner } from "medusa-test-utils" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } + +medusaIntegrationTestRunner({ + env, + testSuite: ({ getContainer }) => { + describe("FulfillmentSet and Location", () => { + let appContainer + let fulfillmentModule: IFulfillmentModuleService + let locationModule: IStockLocationServiceNext + let scService: ISalesChannelModuleService + let remoteQuery + let remoteLink + + beforeAll(async () => { + appContainer = getContainer() + fulfillmentModule = appContainer.resolve( + ModuleRegistrationName.FULFILLMENT + ) + locationModule = appContainer.resolve( + ModuleRegistrationName.STOCK_LOCATION + ) + scService = appContainer.resolve(ModuleRegistrationName.SALES_CHANNEL) + remoteQuery = appContainer.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + remoteLink = appContainer.resolve(ContainerRegistrationKeys.REMOTE_LINK) + }) + + it("should query fulfillment set and location link with remote query", async () => { + const fulfillmentSet = await fulfillmentModule.create({ + name: "Test fulfillment set", + type: "delivery", + }) + + const euWarehouse = await locationModule.create({ + name: "EU Warehouse", + }) + + await remoteLink.create([ + { + [Modules.FULFILLMENT]: { + fulfillment_set_id: fulfillmentSet.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: euWarehouse.id, + }, + }, + ]) + + const linkQuery = remoteQueryObjectFromString({ + entryPoint: "fulfillment_sets", + fields: ["id", "stock_locations.id"], + }) + + const link = await remoteQuery(linkQuery) + + expect(link).toHaveLength(1) + expect(link).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: fulfillmentSet.id, + stock_locations: expect.arrayContaining([ + expect.objectContaining({ + id: euWarehouse.id, + }), + ]), + }), + ]) + ) + }) + }) + }, +}) diff --git a/integration-tests/modules/__tests__/link-modules/sales-channel-location.spec.ts b/integration-tests/modules/__tests__/link-modules/sales-channel-location.spec.ts index 3361fffe63..6d8cce012e 100644 --- a/integration-tests/modules/__tests__/link-modules/sales-channel-location.spec.ts +++ b/integration-tests/modules/__tests__/link-modules/sales-channel-location.spec.ts @@ -56,7 +56,7 @@ medusaIntegrationTestRunner({ sales_channel_id: scWebshop.id, }, [Modules.STOCK_LOCATION]: { - location_id: euWarehouse.id, + stock_location_id: euWarehouse.id, }, }, { @@ -64,7 +64,7 @@ medusaIntegrationTestRunner({ sales_channel_id: scCphStore.id, }, [Modules.STOCK_LOCATION]: { - location_id: euWarehouse.id, + stock_location_id: euWarehouse.id, }, }, { @@ -72,7 +72,7 @@ medusaIntegrationTestRunner({ sales_channel_id: scNycStore.id, }, [Modules.STOCK_LOCATION]: { - location_id: usWarehouse.id, + stock_location_id: usWarehouse.id, }, }, ]) diff --git a/packages/core-flows/src/definition/cart/steps/get-shipping-option-price-sets.ts b/packages/core-flows/src/definition/cart/steps/get-shipping-option-price-sets.ts index 598eca0563..45de989447 100644 --- a/packages/core-flows/src/definition/cart/steps/get-shipping-option-price-sets.ts +++ b/packages/core-flows/src/definition/cart/steps/get-shipping-option-price-sets.ts @@ -3,6 +3,7 @@ import { IPricingModuleService, PricingContext } from "@medusajs/types" import { ContainerRegistrationKeys, MedusaError, + arrayDifference, remoteQueryObjectFromString, } from "@medusajs/utils" import { StepResponse, createStep } from "@medusajs/workflows-sdk" @@ -12,7 +13,7 @@ interface StepInput { context?: Record } -export const getShippingOptionPriceSetsStepId = "get-variant-price-sets" +export const getShippingOptionPriceSetsStepId = "get-shipping-option-price-sets" export const getShippingOptionPriceSetsStep = createStep( getShippingOptionPriceSetsStepId, async (data: StepInput, { container }) => { @@ -38,26 +39,22 @@ export const getShippingOptionPriceSetsStep = createStep( const optionPriceSets = await remoteQuery(query) - const notFound: string[] = [] - const priceSetIds: string[] = [] + const optionsMissingPrices = arrayDifference( + data.optionIds, + optionPriceSets.map((v) => v.shipping_option_id) + ) - optionPriceSets.forEach((v) => { - if (v.price_set_id) { - priceSetIds.push(v.price_set_id) - } else { - notFound.push(v.shipping_option_id) - } - }) - - if (notFound.length) { + if (optionsMissingPrices.length) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `Shipping options with IDs ${notFound.join(", ")} do not have a price` + `Shipping options with IDs ${optionsMissingPrices.join( + ", " + )} do not have a price` ) } const calculatedPriceSets = await pricingModuleService.calculatePrices( - { id: priceSetIds }, + { id: optionPriceSets.map((v) => v.price_set_id) }, { context: data.context as PricingContext["context"] } ) diff --git a/packages/core-flows/src/definition/cart/workflows/index.ts b/packages/core-flows/src/definition/cart/workflows/index.ts index 27b47c7e5f..63bd15dff6 100644 --- a/packages/core-flows/src/definition/cart/workflows/index.ts +++ b/packages/core-flows/src/definition/cart/workflows/index.ts @@ -2,6 +2,7 @@ export * from "./add-shipping-method-to-cart" export * from "./add-to-cart" export * from "./create-carts" export * from "./create-payment-collection-for-cart" +export * from "./list-shipping-options-for-cart" export * from "./refresh-payment-collection" export * from "./update-cart" export * from "./update-cart-promotions" diff --git a/packages/core-flows/src/definition/cart/workflows/list-shipping-options-for-cart.ts b/packages/core-flows/src/definition/cart/workflows/list-shipping-options-for-cart.ts new file mode 100644 index 0000000000..5961d91475 --- /dev/null +++ b/packages/core-flows/src/definition/cart/workflows/list-shipping-options-for-cart.ts @@ -0,0 +1,93 @@ +import { ListShippingOptionsForCartWorkflowInputDTO } from "@medusajs/types" +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" +import { useRemoteQueryStep } from "../../../common/steps/use-remote-query" +import { listShippingOptionsForContextStep } from "../../../shipping-options" +import { getShippingOptionPriceSetsStep } from "../steps" + +export const listShippingOptionsForCartWorkflowId = + "list-shipping-options-for-cart" +export const listShippingOptionsForCartWorkflow = createWorkflow( + listShippingOptionsForCartWorkflowId, + (input: WorkflowData) => { + const scLocationFulfillmentSets = useRemoteQueryStep({ + entry_point: "sales_channels", + fields: ["stock_locations.fulfillment_sets.id"], + variables: { id: input.sales_channel_id }, + }) + + const listOptionsInput = transform( + { scLocationFulfillmentSets, input }, + (data) => { + const fulfillmentSetIds = data.scLocationFulfillmentSets + .map((sc) => + sc.stock_locations.map((loc) => + loc.fulfillment_sets.map(({ id }) => id) + ) + ) + .flat(2) + + return { + context: { + fulfillment_set_id: fulfillmentSetIds, + service_zone: { + geo_zones: { + city: data.input.shipping_address?.city, + country_code: data.input.shipping_address?.country_code, + province_code: data.input.shipping_address?.province, + }, + }, + }, + config: { + select: [ + "id", + "name", + "price_type", + "service_zone_id", + "shipping_profile_id", + "provider_id", + "data", + "amount", + ], + relations: ["type", "provider"], + }, + } + } + ) + + const options = listShippingOptionsForContextStep(listOptionsInput) + + const optionIds = transform({ options }, (data) => + data.options.map((option) => option.id) + ) + + // TODO: Separate shipping options based on price_type, flat_rate vs calculated + const priceSets = getShippingOptionPriceSetsStep({ + optionIds, + context: { + currency_code: input.currency_code, + }, + }) + + const shippingOptionsWithPrice = transform( + { priceSets, options }, + (data) => { + const options = data.options.map((option) => { + const price = data.priceSets?.[option.id].calculated_amount + + return { + ...option, + amount: price, + } + }) + + return options + } + ) + + return shippingOptionsWithPrice + } +) diff --git a/packages/core-flows/src/index.ts b/packages/core-flows/src/index.ts index a04b6ef71a..575556cc43 100644 --- a/packages/core-flows/src/index.ts +++ b/packages/core-flows/src/index.ts @@ -9,6 +9,7 @@ export * from "./payment" export * from "./product" export * from "./promotion" export * from "./region" +export * from "./shipping-options" export * from "./store" export * from "./tax" export * from "./user" diff --git a/packages/core-flows/src/shipping-options/index.ts b/packages/core-flows/src/shipping-options/index.ts new file mode 100644 index 0000000000..c1f49c23fa --- /dev/null +++ b/packages/core-flows/src/shipping-options/index.ts @@ -0,0 +1 @@ +export * from "./steps" diff --git a/packages/core-flows/src/shipping-options/steps/index.ts b/packages/core-flows/src/shipping-options/steps/index.ts new file mode 100644 index 0000000000..3e8969a130 --- /dev/null +++ b/packages/core-flows/src/shipping-options/steps/index.ts @@ -0,0 +1 @@ +export * from "./list-shipping-options-for-context" diff --git a/packages/core-flows/src/shipping-options/steps/list-shipping-options-for-context.ts b/packages/core-flows/src/shipping-options/steps/list-shipping-options-for-context.ts new file mode 100644 index 0000000000..e3e2e465b0 --- /dev/null +++ b/packages/core-flows/src/shipping-options/steps/list-shipping-options-for-context.ts @@ -0,0 +1,30 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + FindConfig, + IFulfillmentModuleService, + ShippingOptionDTO, +} from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +interface StepInput { + context: Record + config?: FindConfig +} + +export const listShippingOptionsForContextStepId = + "list-shipping-options-for-context" +export const listShippingOptionsForContextStep = createStep( + listShippingOptionsForContextStepId, + async (data: StepInput, { container }) => { + const fulfillmentService = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + const shippingOptions = await fulfillmentService.listShippingOptions( + data.context, + data.config + ) + + return new StepResponse(shippingOptions) + } +) diff --git a/packages/fulfillment/src/models/index.ts b/packages/fulfillment/src/models/index.ts index 6c1286e828..72b5f97ed0 100644 --- a/packages/fulfillment/src/models/index.ts +++ b/packages/fulfillment/src/models/index.ts @@ -10,3 +10,4 @@ export { default as ShippingOption } from "./shipping-option" export { default as ShippingOptionRule } from "./shipping-option-rule" export { default as ShippingOptionType } from "./shipping-option-type" export { default as ShippingProfile } from "./shipping-profile" + diff --git a/packages/link-modules/src/definitions/fulfillment-set-location.ts b/packages/link-modules/src/definitions/fulfillment-set-location.ts new file mode 100644 index 0000000000..d09a15b14e --- /dev/null +++ b/packages/link-modules/src/definitions/fulfillment-set-location.ts @@ -0,0 +1,63 @@ +import { Modules } from "@medusajs/modules-sdk" +import { ModuleJoinerConfig } from "@medusajs/types" +import { LINKS } from "../links" + +export const FulfillmentSetLocation: ModuleJoinerConfig = { + serviceName: LINKS.FulfillmentSetLocation, + isLink: true, + databaseConfig: { + tableName: "fulfillment_set_location", + idPrefix: "fsloc", + }, + alias: [ + { + name: ["fulfillment_set_location", "fulfillment_set_locations"], + args: { + entity: "LinkFulfillmentSetLocation", + }, + }, + ], + primaryKeys: ["id", "fulfillment_set_id", "stock_location_id"], + relationships: [ + { + serviceName: Modules.FULFILLMENT, + primaryKey: "id", + foreignKey: "fulfillment_set_id", + alias: "fulfillment_set", + }, + { + serviceName: Modules.STOCK_LOCATION, + primaryKey: "id", + foreignKey: "stock_location_id", + alias: "location", + }, + ], + extends: [ + { + serviceName: Modules.FULFILLMENT, + fieldAlias: { + stock_locations: "locations_link.location", + }, + relationship: { + serviceName: LINKS.FulfillmentSetLocation, + primaryKey: "fulfillment_set_id", + foreignKey: "id", + alias: "locations_link", + isList: true, + }, + }, + { + serviceName: Modules.STOCK_LOCATION, + relationship: { + serviceName: LINKS.FulfillmentSetLocation, + primaryKey: "stock_location_id", + foreignKey: "id", + alias: "fulfillment_set_link", + isList: true, + }, + fieldAlias: { + fulfillment_sets: "fulfillment_set_link.fulfillment_set", + }, + }, + ], +} diff --git a/packages/link-modules/src/definitions/index.ts b/packages/link-modules/src/definitions/index.ts index c44a853a11..39d7f87935 100644 --- a/packages/link-modules/src/definitions/index.ts +++ b/packages/link-modules/src/definitions/index.ts @@ -3,6 +3,7 @@ export * from "./cart-payment-collection" export * from "./cart-promotion" export * from "./cart-region" export * from "./cart-sales-channel" +export * from "./fulfillment-set-location" export * from "./inventory-level-stock-location" export * from "./order-sales-channel" export * from "./product-sales-channel" diff --git a/packages/link-modules/src/definitions/sales-channel-location.ts b/packages/link-modules/src/definitions/sales-channel-location.ts index 89d1dc094d..77e9736cd1 100644 --- a/packages/link-modules/src/definitions/sales-channel-location.ts +++ b/packages/link-modules/src/definitions/sales-channel-location.ts @@ -17,7 +17,7 @@ export const SalesChannelLocation: ModuleJoinerConfig = { }, }, ], - primaryKeys: ["id", "sales_channel_id", "location_id"], + primaryKeys: ["id", "sales_channel_id", "stock_location_id"], relationships: [ { serviceName: Modules.SALES_CHANNEL, @@ -28,7 +28,7 @@ export const SalesChannelLocation: ModuleJoinerConfig = { { serviceName: Modules.STOCK_LOCATION, primaryKey: "id", - foreignKey: "location_id", + foreignKey: "stock_location_id", alias: "location", }, ], @@ -36,7 +36,7 @@ export const SalesChannelLocation: ModuleJoinerConfig = { { serviceName: Modules.SALES_CHANNEL, fieldAlias: { - locations: "locations_link.location", + stock_locations: "locations_link.location", }, relationship: { serviceName: LINKS.SalesChannelLocation, @@ -53,7 +53,7 @@ export const SalesChannelLocation: ModuleJoinerConfig = { }, relationship: { serviceName: LINKS.SalesChannelLocation, - primaryKey: "location_id", + primaryKey: "stock_location_id", foreignKey: "id", alias: "sales_channels_link", isList: true, diff --git a/packages/link-modules/src/links.ts b/packages/link-modules/src/links.ts index d0bab47137..aedf1d9bed 100644 --- a/packages/link-modules/src/links.ts +++ b/packages/link-modules/src/links.ts @@ -44,6 +44,12 @@ export const LINKS = { Modules.STOCK_LOCATION, "location_id" ), + FulfillmentSetLocation: composeLinkName( + Modules.FULFILLMENT, + "fulfillment_set_id", + Modules.STOCK_LOCATION, + "location_id" + ), // Internal services ProductShippingProfile: composeLinkName( diff --git a/packages/medusa/src/api-v2/store/shipping-options/[cart_id]/route.ts b/packages/medusa/src/api-v2/store/shipping-options/[cart_id]/route.ts new file mode 100644 index 0000000000..80e5656b63 --- /dev/null +++ b/packages/medusa/src/api-v2/store/shipping-options/[cart_id]/route.ts @@ -0,0 +1,42 @@ +import { listShippingOptionsForCartWorkflow } from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICartModuleService } from "@medusajs/types" +import { MedusaRequest, MedusaResponse } from "../../../../types/routing" + +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const { cart_id } = req.params + + const cartService = req.scope.resolve( + ModuleRegistrationName.CART + ) + + const cart = await cartService.retrieve(cart_id, { + select: [ + "id", + "sales_channel_id", + "subtotal", + "currency_code", + "shipping_address.city", + "shipping_address.country_code", + "shipping_address.province", + ], + relations: ["shipping_address"], + }) + + const shippingOptions = await listShippingOptionsForCartWorkflow( + req.scope + ).run({ + input: { + cart_id: cart.id, + sales_channel_id: cart.sales_channel_id, + currency_code: cart.currency_code, + shipping_address: { + city: cart.shipping_address?.city, + country_code: cart.shipping_address?.country_code, + province: cart.shipping_address?.province, + }, + }, + }) + + res.json({ shipping_options: shippingOptions }) +} diff --git a/packages/medusa/src/api-v2/store/shipping-options/middlewares.ts b/packages/medusa/src/api-v2/store/shipping-options/middlewares.ts new file mode 100644 index 0000000000..58deccddbb --- /dev/null +++ b/packages/medusa/src/api-v2/store/shipping-options/middlewares.ts @@ -0,0 +1,9 @@ +import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" + +export const storeShippingOptionRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/store/shipping-options/:cart_id", + middlewares: [], + }, +] diff --git a/packages/medusa/src/api-v2/store/shipping-options/query-config.ts b/packages/medusa/src/api-v2/store/shipping-options/query-config.ts new file mode 100644 index 0000000000..07cb17ccfc --- /dev/null +++ b/packages/medusa/src/api-v2/store/shipping-options/query-config.ts @@ -0,0 +1,14 @@ +export const defaultStoreShippingOptionsFields = [ + "id", + "name", + "price_type", + "service_zone_id", + "shipping_profile_id", + "fulfillment_provider_id", + "shipping_option_type_id", +] + +export const listTransformQueryConfig = { + defaultLimit: 20, + isList: true, +} diff --git a/packages/orchestration/src/joiner/remote-joiner.ts b/packages/orchestration/src/joiner/remote-joiner.ts index a42959a5b1..7dd107db12 100644 --- a/packages/orchestration/src/joiner/remote-joiner.ts +++ b/packages/orchestration/src/joiner/remote-joiner.ts @@ -777,7 +777,7 @@ export class RemoteJoiner { // remove alias from fields const parentPath = [BASE_PATH, ...currentPath].join(".") const parentExpands = parsedExpands.get(parentPath) - parentExpands.fields = parentExpands.fields.filter( + parentExpands.fields = parentExpands.fields?.filter( (field) => field !== property ) @@ -787,12 +787,13 @@ export class RemoteJoiner { ) ) + const parentFieldAlias = fullPath[Math.max(fullPath.length - 2, 0)] implodeMapping.push({ location: [...currentPath], property, path: fullPath, isList: !!serviceConfig.relationships?.find( - (relationship) => relationship.alias === fullPath[0] + (relationship) => relationship.alias === parentFieldAlias )?.isList, }) diff --git a/packages/stock-location-next/src/joiner-config.ts b/packages/stock-location-next/src/joiner-config.ts index 6b07dcaeef..2fab8c0a75 100644 --- a/packages/stock-location-next/src/joiner-config.ts +++ b/packages/stock-location-next/src/joiner-config.ts @@ -1,6 +1,6 @@ -import { MapToConfig } from "@medusajs/utils" -import { ModuleJoinerConfig } from "@medusajs/types" import { Modules } from "@medusajs/modules-sdk" +import { ModuleJoinerConfig } from "@medusajs/types" +import { MapToConfig } from "@medusajs/utils" import { StockLocation } from "./models" import moduleSchema from "./schema" diff --git a/packages/types/src/cart/workflows.ts b/packages/types/src/cart/workflows.ts index 2a5c4733f1..be9e50c167 100644 --- a/packages/types/src/cart/workflows.ts +++ b/packages/types/src/cart/workflows.ts @@ -1,4 +1,5 @@ import { CustomerDTO } from "../customer" +import { ShippingOptionDTO } from "../fulfillment" import { ProductDTO } from "../product" import { RegionDTO } from "../region" import { CartDTO, CartLineItemDTO } from "./common" @@ -101,3 +102,18 @@ export interface CartWorkflowDTO extends CartDTO { product?: ProductDTO region?: RegionDTO } + +export interface ListShippingOptionsForCartWorkflowInputDTO { + cart_id: string + sales_channel_id?: string + currency_code: string + shipping_address: { + city?: string + country_code?: string + province?: string + } +} + +export interface PricedShippingOptionDTO extends ShippingOptionDTO { + amount: number +} diff --git a/packages/types/src/fulfillment/common/shipping-option.ts b/packages/types/src/fulfillment/common/shipping-option.ts index b76403b20b..7e4bf76d4c 100644 --- a/packages/types/src/fulfillment/common/shipping-option.ts +++ b/packages/types/src/fulfillment/common/shipping-option.ts @@ -1,16 +1,16 @@ -import { FilterableServiceZoneProps, ServiceZoneDTO } from "./service-zone" -import { ShippingProfileDTO } from "./shipping-profile" +import { BaseFilterable, OperatorMap } from "../../dal" +import { FulfillmentDTO } from "./fulfillment" import { FulfillmentProviderDTO } from "./fulfillment-provider" -import { - FilterableShippingOptionTypeProps, - ShippingOptionTypeDTO, -} from "./shipping-option-type" +import { FilterableServiceZoneProps, ServiceZoneDTO } from "./service-zone" import { FilterableShippingOptionRuleProps, ShippingOptionRuleDTO, } from "./shipping-option-rule" -import { BaseFilterable, OperatorMap } from "../../dal" -import { FulfillmentDTO } from "./fulfillment" +import { + FilterableShippingOptionTypeProps, + ShippingOptionTypeDTO, +} from "./shipping-option-type" +import { ShippingProfileDTO } from "./shipping-profile" export type ShippingOptionPriceType = "calculated" | "flat" @@ -40,6 +40,7 @@ export interface FilterableShippingOptionProps id?: string | string[] | OperatorMap name?: string | string[] | OperatorMap fulfillment_set_id?: string | string[] | OperatorMap + shipping_profile_id?: string | string[] | OperatorMap fulfillment_set_type?: string | string[] | OperatorMap price_type?: | ShippingOptionPriceType