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
This commit is contained in:
Pedro Guzman
2025-04-09 19:15:42 +02:00
committed by GitHub
parent f615ebb7e8
commit 8804ca2f9c
6 changed files with 634 additions and 17 deletions

View File

@@ -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", () => {

View File

@@ -80,7 +80,7 @@ async function populateData(api: any) {
console.log(err)
})
await setTimeout(2000)
await setTimeout(10000)
}
process.env.ENABLE_INDEX_MODULE = "true"

View File

@@ -153,7 +153,7 @@ medusaIntegrationTestRunner({
})
// Timeout to allow indexing to finish
await setTimeout(4000)
await setTimeout(10000)
const { data: results } = await fetchAndRetry(
async () =>

View File

@@ -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)

View File

@@ -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)
})
})

View File

@@ -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<string>()
const allVariants = new Map<string, any>()
const mapLocationAvailability = new Map<string, Map<string, BigNumberInput>>()
let hasSalesChannelStockLocation = false
const variantsWithLocationForChannel = new Set<string>()
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({