From 8804ca2f9c75a9eab0a5fe03747334eb9e9a5524 Mon Sep 17 00:00:00 2001 From: Pedro Guzman Date: Wed, 9 Apr 2025 19:15:42 +0200 Subject: [PATCH] fix: allow backorder variants to be added to cart even if no locations (#12083) * fix: allow backorder variants to be added to cart even if no locations * document and unit test prepareConfirmInventoryInput --- .../http/__tests__/cart/store/cart.spec.ts | 67 ++- .../__tests__/index/query-index.spec.ts | 2 +- .../modules/__tests__/index/search.spec.ts | 2 +- .../__tests__/order/draft-order.spec.ts | 10 +- .../prepare-confirm-inventory-input.spec.ts | 533 ++++++++++++++++++ .../utils/prepare-confirm-inventory-input.ts | 37 +- 6 files changed, 634 insertions(+), 17 deletions(-) create mode 100644 packages/core/core-flows/src/cart/utils/__tests__/prepare-confirm-inventory-input.spec.ts diff --git a/integration-tests/http/__tests__/cart/store/cart.spec.ts b/integration-tests/http/__tests__/cart/store/cart.spec.ts index 4880a33667..99c4e3539d 100644 --- a/integration-tests/http/__tests__/cart/store/cart.spec.ts +++ b/integration-tests/http/__tests__/cart/store/cart.spec.ts @@ -151,7 +151,7 @@ medusaIntegrationTestRunner({ }) describe("POST /store/carts", () => { - it("should succesffully create a cart", async () => { + it("should successfully create a cart", async () => { const response = await api.post( `/store/carts`, { @@ -255,10 +255,10 @@ medusaIntegrationTestRunner({ }) describe("POST /store/carts/:id/line-items", () => { - let shippingOption, shippingOptionExpensive + let shippingOption, shippingOptionExpensive, stockLocation beforeEach(async () => { - const stockLocation = ( + stockLocation = ( await api.post( `/admin/stock-locations`, { name: "test location" }, @@ -894,6 +894,67 @@ medusaIntegrationTestRunner({ ) }) }) + + describe("with manage_inventory true", () => { + let inventoryItem + beforeEach(async () => { + await api.post( + `/admin/products/${product.id}/variants/${product.variants[0].id}`, + { manage_inventory: true }, + adminHeaders + ) + + inventoryItem = ( + await api.post( + `/admin/inventory-items`, + { sku: "bottle" }, + adminHeaders + ) + ).data.inventory_item + }) + + describe("with allow_backorder true", () => { + beforeEach(async () => { + await api.post( + `/admin/products/${product.id}/variants/${product.variants[0].id}`, + { allow_backorder: true }, + adminHeaders + ) + }) + + it("should add item to cart even if no inventory locations", async () => { + let response = await api.post( + `/store/carts/${cart.id}/line-items`, + { + variant_id: product.variants[0].id, + quantity: 1, + }, + storeHeaders + ) + + expect(response.status).toEqual(200) + }) + + it("should add item to cart even if inventory is empty", async () => { + await api.post( + `/admin/inventory-items/${inventoryItem.id}/location-levels/batch`, + { create: [{ location_id: stockLocation.id }] }, + adminHeaders + ) + + let response = await api.post( + `/store/carts/${cart.id}/line-items`, + { + variant_id: product.variants[0].id, + quantity: 1, + }, + storeHeaders + ) + + expect(response.status).toEqual(200) + }) + }) + }) }) describe("POST /store/carts/:id/line-items/:id", () => { diff --git a/integration-tests/modules/__tests__/index/query-index.spec.ts b/integration-tests/modules/__tests__/index/query-index.spec.ts index 3bb6cef251..f8badebb23 100644 --- a/integration-tests/modules/__tests__/index/query-index.spec.ts +++ b/integration-tests/modules/__tests__/index/query-index.spec.ts @@ -80,7 +80,7 @@ async function populateData(api: any) { console.log(err) }) - await setTimeout(2000) + await setTimeout(10000) } process.env.ENABLE_INDEX_MODULE = "true" diff --git a/integration-tests/modules/__tests__/index/search.spec.ts b/integration-tests/modules/__tests__/index/search.spec.ts index 13ac6a71a1..c442b53aa5 100644 --- a/integration-tests/modules/__tests__/index/search.spec.ts +++ b/integration-tests/modules/__tests__/index/search.spec.ts @@ -153,7 +153,7 @@ medusaIntegrationTestRunner({ }) // Timeout to allow indexing to finish - await setTimeout(4000) + await setTimeout(10000) const { data: results } = await fetchAndRetry( async () => diff --git a/integration-tests/modules/__tests__/order/draft-order.spec.ts b/integration-tests/modules/__tests__/order/draft-order.spec.ts index 78d35f1985..ac0cda5291 100644 --- a/integration-tests/modules/__tests__/order/draft-order.spec.ts +++ b/integration-tests/modules/__tests__/order/draft-order.spec.ts @@ -17,7 +17,7 @@ import { } from "../../../helpers/create-admin-user" import { setupTaxStructure } from "../fixtures" -jest.setTimeout(50000) +jest.setTimeout(100000) const env = { MEDUSA_FF_MEDUSA_V2: true } @@ -165,6 +165,14 @@ medusaIntegrationTestRunner({ inventory_item_id: inventoryItem.id, }, }, + { + [Modules.PRODUCT]: { + variant_id: product_2.variants[0].id, + }, + [Modules.INVENTORY]: { + inventory_item_id: inventoryItem.id, + }, + }, ]) await setupTaxStructure(taxModule) diff --git a/packages/core/core-flows/src/cart/utils/__tests__/prepare-confirm-inventory-input.spec.ts b/packages/core/core-flows/src/cart/utils/__tests__/prepare-confirm-inventory-input.spec.ts new file mode 100644 index 0000000000..3180685719 --- /dev/null +++ b/packages/core/core-flows/src/cart/utils/__tests__/prepare-confirm-inventory-input.spec.ts @@ -0,0 +1,533 @@ +import { ConfirmVariantInventoryWorkflowInputDTO } from "@medusajs/framework/types" +import { MedusaError } from "@medusajs/framework/utils" +import { prepareConfirmInventoryInput } from "../prepare-confirm-inventory-input" + +describe("prepareConfirmInventoryInput", () => { + it("should use the quantity from the itemsToUpdate", () => { + const input: ConfirmVariantInventoryWorkflowInputDTO = { + sales_channel_id: "sc_1", + variants: [ + { + id: "pv_1", + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: "ii_1", + variant_id: "pv_1", + required_quantity: 3, + inventory: [ + { + location_levels: { + stock_locations: [ + { + id: "sl_1", + sales_channels: [{ id: "sc_1" }], + }, + ], + }, + }, + ], + }, + ], + }, + ], + items: [ + { + variant_id: "pv_1", + quantity: 1, + id: "item_1", + }, + ], + itemsToUpdate: [ + { + variant_id: "pv_1", + quantity: 2, + }, + ], + } + + const output = prepareConfirmInventoryInput({ input }) + + expect(output).toEqual({ + items: [ + { + id: "item_1", + inventory_item_id: "ii_1", + required_quantity: 3, + quantity: 2, // overrides the quantity from the items array + allow_backorder: false, + location_ids: ["sl_1"], + }, + ], + }) + }) + + it("should only return variants with manage_inventory set to true", () => { + const input: ConfirmVariantInventoryWorkflowInputDTO = { + sales_channel_id: "sc_1", + variants: [ + { + id: "pv_1", + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: "ii_1", + variant_id: "pv_1", + required_quantity: 1, + inventory: [ + { + location_levels: { + stock_locations: [ + { + id: "sl_1", + sales_channels: [{ id: "sc_1" }], + }, + ], + }, + }, + ], + }, + ], + }, + { + id: "pv_2", + manage_inventory: false, + inventory_items: [ + { + inventory_item_id: "ii_2", + variant_id: "pv_2", + required_quantity: 1, + inventory: [ + { + location_levels: { + stock_locations: [ + { + id: "sl_1", + sales_channels: [{ id: "sc_1" }], + }, + ], + }, + }, + ], + }, + ], + }, + ], + items: [ + { + variant_id: "pv_1", + quantity: 1, + id: "item_1", + }, + { + variant_id: "pv_2", + quantity: 1, + id: "item_2", + }, + ], + } + + const output = prepareConfirmInventoryInput({ input }) + + expect(output).toEqual({ + items: [ + { + id: "item_1", + inventory_item_id: "ii_1", + required_quantity: 1, + quantity: 1, + allow_backorder: false, + location_ids: ["sl_1"], + }, + ], + }) + }) + + it("should return all inventory items for a variant", () => { + const input: ConfirmVariantInventoryWorkflowInputDTO = { + sales_channel_id: "sc_1", + variants: [ + { + id: "pv_1", + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: "ii_1", + variant_id: "pv_1", + required_quantity: 1, + inventory: [ + { + location_levels: { + stock_locations: [ + { + id: "sl_1", + sales_channels: [{ id: "sc_1" }], + }, + ], + }, + }, + ], + }, + { + inventory_item_id: "ii_2", + variant_id: "pv_1", + required_quantity: 2, + inventory: [ + { + location_levels: { + stock_locations: [ + { + id: "sl_1", + sales_channels: [{ id: "sc_1" }], + }, + ], + }, + }, + ], + }, + ], + }, + ], + items: [ + { + variant_id: "pv_1", + quantity: 1, + id: "item_1", + }, + ], + } + + const output = prepareConfirmInventoryInput({ input }) + + expect(output).toEqual({ + items: [ + { + id: "item_1", + inventory_item_id: "ii_1", + required_quantity: 1, + quantity: 1, + allow_backorder: false, + location_ids: ["sl_1"], + }, + { + id: "item_1", + inventory_item_id: "ii_2", + required_quantity: 2, + quantity: 1, + allow_backorder: false, + location_ids: ["sl_1"], + }, + ], + }) + }) + + it("should throw an error if any variant has no stock locations linked to the sales channel", () => { + const input = { + sales_channel_id: "sc_1", + variants: [ + { + id: "pv_1", + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: "ii_1", + variant_id: "pv_1", + required_quantity: 1, + inventory: [ + { + location_levels: { + stock_locations: [ + { + id: "sl_1", + sales_channels: [{ id: "sc_1" }], + }, + ], + }, + }, + ], + }, + ], + }, + { + id: "pv_2", + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: "ii_2", + variant_id: "pv_2", + required_quantity: 1, + inventory: [ + { + location_levels: { + stock_locations: [ + { + id: "sl_2", + sales_channels: [{ id: "sc_2" }], // Different sales channel + }, + ], + }, + }, + ], + }, + ], + }, + ], + items: [ + { + variant_id: "pv_1", + quantity: 1, + id: "item_1", + }, + { + variant_id: "pv_2", + quantity: 1, + id: "item_2", + }, + ], + } + + expect(() => prepareConfirmInventoryInput({ input })).toThrow(MedusaError) + }) + + it("if allow_backorder is true, it should return normally even if there's no stock location for the sales channel", () => { + const input = { + sales_channel_id: "sc_1", + variants: [ + { + id: "pv_1", + manage_inventory: true, + allow_backorder: true, + inventory_items: [ + { + inventory_item_id: "ii_1", + variant_id: "pv_1", + required_quantity: 1, + inventory: [ + { + location_levels: { + stock_locations: [ + { + id: "sl_2", + sales_channels: [{ id: "sc_2" }], // Different sales channel + }, + ], + }, + }, + ], + }, + ], + }, + ], + items: [ + { + variant_id: "pv_1", + quantity: 1, + id: "item_1", + }, + ], + } + + const result = prepareConfirmInventoryInput({ input }) + + expect(result).toEqual({ + items: [ + { + id: "item_1", + inventory_item_id: "ii_1", + required_quantity: 1, + quantity: 1, + allow_backorder: true, + location_ids: [], // TODO: what should this be? + }, + ], + }) + }) + + it("should return only stock locations with availability, if any", () => { + const input = { + sales_channel_id: "sc_1", + variants: [ + { + id: "pv_1", + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: "ii_1", + variant_id: "pv_1", + required_quantity: 1, + inventory: [ + { + location_levels: { + stocked_quantity: 10, // 10 - 9 = 1 < 2: no availability + reserved_quantity: 9, + location_id: "sl_1", + stock_locations: [ + { + id: "sl_1", + sales_channels: [{ id: "sc_1" }], + }, + ], + }, + }, + { + location_levels: { + stocked_quantity: 7, // 7 - 5 = 2 >= 2: availability + reserved_quantity: 5, + location_id: "sl_2", + stock_locations: [ + { + id: "sl_2", + sales_channels: [{ id: "sc_1" }], + }, + ], + }, + }, + ], + }, + ], + }, + ], + items: [ + { + variant_id: "pv_1", + quantity: 2, + id: "item_1", + }, + ], + } + + const result = prepareConfirmInventoryInput({ input }) + + expect(result).toEqual({ + items: [ + { + id: "item_1", + inventory_item_id: "ii_1", + required_quantity: 1, + quantity: 2, + allow_backorder: false, + location_ids: ["sl_2"], // Only includes location with available stock + }, + ], + }) + }) + + it("should return all locations if none has availability", () => { + const input = { + sales_channel_id: "sc_1", + variants: [ + { + id: "pv_1", + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: "ii_1", + variant_id: "pv_1", + required_quantity: 1, + inventory: [ + { + location_levels: { + stocked_quantity: 1, + reserved_quantity: 1, // 1 - 1 = 0 < 2: no availability + location_id: "sl_1", + stock_locations: [ + { + id: "sl_1", + sales_channels: [{ id: "sc_1" }], + }, + ], + }, + }, + { + location_levels: { + stocked_quantity: 4, + reserved_quantity: 3, // 4 - 3 = 1 < 2: no availability + location_id: "sl_2", + stock_locations: [ + { + id: "sl_2", + sales_channels: [{ id: "sc_1" }], + }, + ], + }, + }, + ], + }, + ], + }, + ], + items: [ + { + variant_id: "pv_1", + quantity: 2, + id: "item_1", + }, + ], + } + + const result = prepareConfirmInventoryInput({ input }) + + expect(result).toEqual({ + items: [ + { + id: "item_1", + inventory_item_id: "ii_1", + required_quantity: 1, + quantity: 2, + allow_backorder: false, + location_ids: ["sl_1", "sl_2"], // Includes all locations since none has availability + }, + ], + }) + }) + + it("should throw an error if any variant has no inventory items", () => { + const input = { + sales_channel_id: "sc_1", + variants: [ + { + id: "pv_1", + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: "ii_1", + variant_id: "pv_1", + required_quantity: 3, + inventory: [ + { + location_levels: { + stock_locations: [ + { + id: "sl_1", + sales_channels: [{ id: "sc_1" }], + }, + ], + }, + }, + ], + }, + ], + }, + { + id: "pv_2", + manage_inventory: true, + inventory_items: [], // No inventory items + }, + ], + items: [ + { + variant_id: "pv_1", + quantity: 1, + id: "item_1", + }, + { + variant_id: "pv_2", + quantity: 1, + id: "item_2", + }, + ], + } + + expect(() => prepareConfirmInventoryInput({ input })).toThrow(MedusaError) + }) +}) diff --git a/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts b/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts index 2318ed5cfa..31e392e922 100644 --- a/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts +++ b/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts @@ -38,6 +38,17 @@ interface ConfirmInventoryItem { location_ids: string[] } +/** + * This function prepares the input for the confirm inventory workflow. + * In essesnce, it maps a list of cart items to a list of inventory items, + * serving as a bridge between the cart and inventory domains. + * + * @throws {MedusaError} INVALID_DATA if any cart item is for a variant that has no inventory items. + * @throws {MedusaError} INVALID_DATA if any cart item is for a variant with no stock locations in the input.sales_channel_id. An exception is made for variants with allow_backorder set to true. + * + * @returns {ConfirmInventoryPreparationInput} + * A list of inventory items to confirm. Only inventory items for variants with managed inventory are included. + */ export const prepareConfirmInventoryInput = (data: { input: ConfirmVariantInventoryWorkflowInputDTO }) => { @@ -45,7 +56,7 @@ export const prepareConfirmInventoryInput = (data: { const stockLocationIds = new Set() const allVariants = new Map() const mapLocationAvailability = new Map>() - let hasSalesChannelStockLocation = false + const variantsWithLocationForChannel = new Set() let hasManagedInventory = false const salesChannelId = data.input.sales_channel_id @@ -75,11 +86,8 @@ export const prepareConfirmInventoryInput = (data: { return } - if ( - !hasSalesChannelStockLocation && - sales_channels?.id === salesChannelId - ) { - hasSalesChannelStockLocation = true + if (salesChannelId && sales_channels?.id === salesChannelId) { + variantsWithLocationForChannel.add(variants.id) } if (location_levels && inventory_items) { @@ -140,11 +148,18 @@ export const prepareConfirmInventoryInput = (data: { return { items: [] } } - if (salesChannelId && !hasSalesChannelStockLocation) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Sales channel ${salesChannelId} is not associated with any stock location.` - ) + if (salesChannelId) { + for (const variant of allVariants.values()) { + if ( + !variantsWithLocationForChannel.has(variant.id) && + !variant.allow_backorder + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Sales channel ${salesChannelId} is not associated with any stock location for variant ${variant.id}.` + ) + } + } } const items = formatInventoryInput({