diff --git a/integration-tests/http/__tests__/shipping-option/store/shipping-option.spec.ts b/integration-tests/http/__tests__/shipping-option/store/shipping-option.spec.ts index 520a946eef..8cebfe629d 100644 --- a/integration-tests/http/__tests__/shipping-option/store/shipping-option.spec.ts +++ b/integration-tests/http/__tests__/shipping-option/store/shipping-option.spec.ts @@ -326,5 +326,402 @@ medusaIntegrationTestRunner({ }) }) }) + + describe("with insufficient inventory", () => { + let appContainer + let salesChannel + let region + let product + let stockLocation1 + let stockLocation2 + let stockLocation3 + let shippingProfile + let fulfillmentSet + let shippingOption1 + let shippingOption2 + let shippingOption3 + let storeHeaders + + beforeAll(async () => { + appContainer = getContainer() + }) + + beforeEach(async () => { + const publishableKey = await generatePublishableKey(appContainer) + storeHeaders = generateStoreHeaders({ publishableKey }) + + await createAdminUser(dbConnection, adminHeaders, appContainer) + + region = ( + await api.post( + "/admin/regions", + { name: "US", currency_code: "usd", countries: ["us"] }, + adminHeaders + ) + ).data.region + + shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: "Test", type: "default" }, + adminHeaders + ) + ).data.shipping_profile + + salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "first channel", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + + stockLocation1 = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + const fulfillmentSets1 = ( + await api.post( + `/admin/stock-locations/${stockLocation1.id}/fulfillment-sets?fields=*fulfillment_sets`, + { + name: "Test 1", + type: "pickup", + }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets1[0].id}/service-zones`, + { + name: "Test 1", + geo_zones: [ + { type: "country", country_code: "us" }, + { type: "country", country_code: "dk" }, + ], + }, + adminHeaders + ) + ).data.fulfillment_set + + await api.post( + `/admin/stock-locations/${stockLocation1.id}/fulfillment-providers`, + { add: ["manual_test-provider"] }, + adminHeaders + ) + + shippingOption1 = ( + await api.post( + `/admin/shipping-options`, + { + name: "Pickup option 1", + 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", + }, + prices: [ + { + currency_code: "usd", + amount: 0, + }, + { + region_id: region.id, + amount: 0, + }, + ], + rules: [], + }, + adminHeaders + ) + ).data.shipping_option + + stockLocation2 = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + const fulfillmentSets2 = ( + await api.post( + `/admin/stock-locations/${stockLocation2.id}/fulfillment-sets?fields=*fulfillment_sets`, + { + name: "Test 2", + type: "pickup", + }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets2[0].id}/service-zones`, + { + name: "Test 2", + geo_zones: [ + { type: "country", country_code: "us" }, + { type: "country", country_code: "dk" }, + ], + }, + adminHeaders + ) + ).data.fulfillment_set + + await api.post( + `/admin/stock-locations/${stockLocation2.id}/fulfillment-providers`, + { add: ["manual_test-provider"] }, + adminHeaders + ) + + shippingOption2 = ( + await api.post( + `/admin/shipping-options`, + { + name: "Pickup option 2", + 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", + }, + prices: [ + { + currency_code: "usd", + amount: 0, + }, + { + region_id: region.id, + amount: 0, + }, + ], + rules: [], + }, + adminHeaders + ) + ).data.shipping_option + + stockLocation3 = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + const fulfillmentSets3 = ( + await api.post( + `/admin/stock-locations/${stockLocation3.id}/fulfillment-sets?fields=*fulfillment_sets`, + { + name: "Test 3", + type: "pickup", + }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets3[0].id}/service-zones`, + { + name: "Test 3", + geo_zones: [ + { type: "country", country_code: "us" }, + { type: "country", country_code: "dk" }, + ], + }, + adminHeaders + ) + ).data.fulfillment_set + + await api.post( + `/admin/stock-locations/${stockLocation3.id}/fulfillment-providers`, + { add: ["manual_test-provider"] }, + adminHeaders + ) + + shippingOption3 = ( + await api.post( + `/admin/shipping-options`, + { + name: "Pickup option 3", + 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", + }, + prices: [ + { + currency_code: "usd", + amount: 0, + }, + { + region_id: region.id, + amount: 0, + }, + ], + rules: [], + }, + adminHeaders + ) + ).data.shipping_option + + const inventoryItem = ( + await api.post( + `/admin/inventory-items`, + { sku: "inventory-item" }, + adminHeaders + ) + ).data.inventory_item + + await api.post( + `/admin/inventory-items/${inventoryItem.id}/location-levels`, + { + location_id: stockLocation1.id, + stocked_quantity: 10, + }, + adminHeaders + ) + + await api.post( + `/admin/inventory-items/${inventoryItem.id}/location-levels`, + { + location_id: stockLocation2.id, + stocked_quantity: 5, + }, + adminHeaders + ) + + // STOCK LOCATION 3 doesn't have any inventory for that item + + await api.post( + `/admin/stock-locations/${stockLocation1.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) + + await api.post( + `/admin/stock-locations/${stockLocation2.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) + + await api.post( + `/admin/stock-locations/${stockLocation3.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) + + product = ( + await api.post( + "/admin/products", + { + title: "Test prod", + options: [ + { title: "size", values: ["large", "small"] }, + { title: "color", values: ["green"] }, + ], + shipping_profile_id: shippingProfile.id, + variants: [ + { + title: "Test variant", + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: inventoryItem.id, + required_quantity: 1, + }, + ], + prices: [ + { + currency_code: "usd", + amount: 100, + }, + { + currency_code: "dkk", + amount: 100, + }, + ], + options: { + size: "large", + color: "green", + }, + }, + ], + }, + adminHeaders + ) + ).data.product + }) + + it("should get shipping options for a cart with insufficient inventory flag set correctly", async () => { + const cart = ( + await api.post( + `/store/carts`, + { + region_id: region.id, + sales_channel_id: salesChannel.id, + currency_code: "usd", + email: "test@admin.com", + items: [ + { + variant_id: product.variants[0].id, + quantity: 8, + }, + ], + }, + storeHeaders + ) + ).data.cart + + const resp = await api.get( + `/store/shipping-options?cart_id=${cart.id}`, + storeHeaders + ) + + const shippingOptions = resp.data.shipping_options + + expect(shippingOptions.length).toBe(3) + expect(shippingOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: shippingOption1.id, + name: "Pickup option 1", + amount: 0, + insufficient_inventory: false, // sufficient inventory at location + }), + expect.objectContaining({ + id: shippingOption2.id, + name: "Pickup option 2", + amount: 0, + insufficient_inventory: true, // inventory item is at location 2 but not enough quantity + }), + expect.objectContaining({ + id: shippingOption3.id, + name: "Pickup option 3", + amount: 0, + insufficient_inventory: true, // inventory item is not at location 3 + }), + ]) + ) + }) + }) }, }) diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-option-details-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-option-details-form.tsx index d82a408bdf..3d6c61bddb 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-option-details-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-create/components/create-shipping-options-form/create-shipping-option-details-form.tsx @@ -247,21 +247,15 @@ export const CreateShippingOptionDetailsForm = ({ /> - {!isPickup && ( - <> - - - - )} + + ) diff --git a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-edit/components/edit-region-form/edit-shipping-option-form.tsx b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-edit/components/edit-region-form/edit-shipping-option-form.tsx index c33fe0b6dd..4be44b2b93 100644 --- a/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-edit/components/edit-region-form/edit-shipping-option-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-service-zone-shipping-option-edit/components/edit-region-form/edit-shipping-option-form.tsx @@ -202,21 +202,17 @@ export const EditShippingOptionForm = ({ /> - {!isPickup && ( - <> - - - - )} + + diff --git a/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts b/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts index 255eda18c8..1ca7c9bbcd 100644 --- a/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts @@ -45,7 +45,14 @@ export const listShippingOptionsForCartWorkflow = createWorkflow( const cartQuery = useQueryGraphStep({ entity: "cart", filters: { id: input.cart_id }, - fields: cartFieldsForPricingContext, + fields: [ + ...cartFieldsForPricingContext, + "items.*", + "items.variant.manage_inventory", + "items.variant.inventory_items.inventory_item_id", + "items.variant.inventory_items.inventory.requires_shipping", + "items.variant.inventory_items.inventory.location_levels.*", + ], options: { throwIfKeyNotFound: true }, }).config({ name: "get-cart" }) @@ -132,6 +139,7 @@ export const listShippingOptionsForCartWorkflow = createWorkflow( "data", "service_zone.fulfillment_set_id", "service_zone.fulfillment_set.type", + "service_zone.fulfillment_set.location.id", "service_zone.fulfillment_set.location.address.*", "type.id", @@ -154,15 +162,42 @@ export const listShippingOptionsForCartWorkflow = createWorkflow( }).config({ name: "shipping-options-query" }) const shippingOptionsWithPrice = transform( - { shippingOptions }, - ({ shippingOptions }) => + { shippingOptions, cart }, + ({ shippingOptions, cart }) => shippingOptions.map((shippingOption) => { const price = shippingOption.calculated_price + const locationId = + shippingOption.service_zone.fulfillment_set.location.id + + const itemsAtLocationWithoutAvailableQuantity = cart.items.filter( + (item) => { + if (!item.variant.manage_inventory) { + return false + } + + return item.variant.inventory_items.some((inventoryItem) => { + if (!inventoryItem.inventory.requires_shipping) { + return false + } + + const level = inventoryItem.inventory.location_levels.find( + (locationLevel) => { + return locationLevel.location_id === locationId + } + ) + + return !level ? true : level.available_quantity < item.quantity + }) + } + ) + return { ...shippingOption, amount: price?.calculated_amount, is_tax_inclusive: !!price?.is_calculated_price_tax_inclusive, + insufficient_inventory: + itemsAtLocationWithoutAvailableQuantity.length > 0, } }) ) diff --git a/packages/core/types/src/http/fulfillment/store/index.ts b/packages/core/types/src/http/fulfillment/store/index.ts index 23a6e449e5..36e37c2483 100644 --- a/packages/core/types/src/http/fulfillment/store/index.ts +++ b/packages/core/types/src/http/fulfillment/store/index.ts @@ -84,4 +84,9 @@ export interface StoreCartShippingOption { * Calculated price for the shipping option */ calculated_price: StoreCalculatedPrice + + /** + * Whether the stock location of the shipping option has insufficient inventory for items in the cart. + */ + insufficient_inventory: boolean }