Skip to content

Commit f2c9d7d

Browse files
authored
fix(core-flows): honor item-level allow_backorder when confirming inventory (#15731)
## Summary **What** — Honor item-level `allow_backorder` when confirming inventory for draft-order items. This allows admins to add out-of-stock variants to a draft order by setting `allow_backorder: true` on the item, regardless of the variant's own backorder setting. **Why** — Addresses the inventory-confirmation part of #14106. Although `allow_backorder` was accepted by the admin API, it was dropped before inventory confirmation, causing out-of-stock variants to be rejected even when backordering was explicitly requested. **How** — * Updated `prepareConfirmInventoryInput` to consider item-level `allow_backorder` alongside the variant configuration. * Threaded `allow_backorder?: boolean` through `NewItem` and `ConfirmVariantInventoryWorkflowInputDTO["items"]` so the value reaches `confirmInventoryStep`. * Applied the same logic to sales-channel stock-location validation. * Backward compatible: the field is optional and store cart endpoints do not accept it. **Testing** — * Added unit tests covering item-level backorder overrides and stock-location validation behavior. * Added integration tests verifying that out-of-stock variants are rejected without the flag and accepted when `allow_backorder: true` is provided. --- ## Examples ```ts await api.post(`/admin/draft-orders/${draftOrderId}/edit/items`, { items: [ { variant_id: "variant_123", quantity: 1, allow_backorder: true, }, ], }) ``` --- ## Checklist * [x] I have added a **changeset** for this PR (patch: `@medusajs/core-flows`, `@medusajs/types`) * [x] The changes are covered by relevant **tests** * [x] I have verified the code works as intended locally * [x] I have linked the related issue(s) if applicable --- ## Additional Context Related to #14106. This PR only addresses the inventory-confirmation bug. The reservation-linking concern mentioned in the issue remains out of scope.
1 parent 8f845bd commit f2c9d7d

6 files changed

Lines changed: 244 additions & 2 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@medusajs/core-flows": patch
3+
"@medusajs/types": patch
4+
---
5+
6+
fix(core-flows,types): respect item-level allow_backorder when confirming inventory
7+
8+
When adding an item, the `allow_backorder` flag passed on the item is now honored during inventory confirmation, overriding the variant's own `allow_backorder` setting for that item only. Previously the flag was accepted by the API but ignored, making it impossible to add an out-of-stock variant to a draft order even with `allow_backorder: true`.

integration-tests/http/__tests__/draft-order/admin/draft-order.spec.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1831,5 +1831,105 @@ medusaIntegrationTestRunner({
18311831
)
18321832
})
18331833
})
1834+
1835+
describe("POST /draft-orders/:id/edit/items - allow_backorder", () => {
1836+
let outOfStockProduct
1837+
let outOfStockInventoryItem
1838+
1839+
beforeEach(async () => {
1840+
outOfStockInventoryItem = (
1841+
await api.post(
1842+
`/admin/inventory-items`,
1843+
{ sku: "out-of-stock-sku" },
1844+
adminHeaders
1845+
)
1846+
).data.inventory_item
1847+
1848+
// A location level exists for the sales channel, but with no
1849+
// available stock, so the variant is out of stock.
1850+
await api.post(
1851+
`/admin/inventory-items/${outOfStockInventoryItem.id}/location-levels`,
1852+
{
1853+
location_id: stockLocation.id,
1854+
stocked_quantity: 0,
1855+
},
1856+
adminHeaders
1857+
)
1858+
1859+
outOfStockProduct = (
1860+
await api.post(
1861+
"/admin/products",
1862+
{
1863+
title: "Out of stock product",
1864+
status: ProductStatus.PUBLISHED,
1865+
options: [{ title: "size", values: ["large"] }],
1866+
variants: [
1867+
{
1868+
title: "L shirt",
1869+
options: { size: "large" },
1870+
manage_inventory: true,
1871+
allow_backorder: false,
1872+
inventory_items: [
1873+
{
1874+
inventory_item_id: outOfStockInventoryItem.id,
1875+
required_quantity: 1,
1876+
},
1877+
],
1878+
prices: [{ currency_code: "usd", amount: 10 }],
1879+
},
1880+
],
1881+
},
1882+
adminHeaders
1883+
)
1884+
).data.product
1885+
1886+
await api.post(
1887+
`/admin/draft-orders/${testDraftOrder.id}/edit`,
1888+
{},
1889+
adminHeaders
1890+
)
1891+
})
1892+
1893+
it("should not allow adding an out-of-stock variant without allow_backorder", async () => {
1894+
const variantId = outOfStockProduct.variants[0].id
1895+
1896+
const error = await api
1897+
.post(
1898+
`/admin/draft-orders/${testDraftOrder.id}/edit/items`,
1899+
{ items: [{ variant_id: variantId, quantity: 1 }] },
1900+
adminHeaders
1901+
)
1902+
.catch((e) => e)
1903+
1904+
expect(error.response.status).toBe(400)
1905+
expect(error.response.data.message).toContain(
1906+
"does not have the required inventory"
1907+
)
1908+
})
1909+
1910+
it("should allow adding an out-of-stock variant when allow_backorder is true on the item", async () => {
1911+
const variantId = outOfStockProduct.variants[0].id
1912+
1913+
const response = await api.post(
1914+
`/admin/draft-orders/${testDraftOrder.id}/edit/items`,
1915+
{
1916+
items: [
1917+
{ variant_id: variantId, quantity: 1, allow_backorder: true },
1918+
],
1919+
},
1920+
adminHeaders
1921+
)
1922+
1923+
expect(response.status).toBe(200)
1924+
expect(response.data.draft_order_preview.items).toEqual(
1925+
expect.arrayContaining([
1926+
expect.objectContaining({
1927+
variant_id: variantId,
1928+
quantity: 1,
1929+
}),
1930+
])
1931+
)
1932+
})
1933+
})
18341934
},
18351935
})

packages/core/core-flows/src/cart/utils/__tests__/prepare-confirm-inventory-input.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,119 @@ describe("prepareConfirmInventoryInput", () => {
325325
})
326326
})
327327

328+
it("if an item opts into allow_backorder, it should override a variant that doesn't allow backorder", () => {
329+
const input = {
330+
sales_channel_id: "sc_1",
331+
variants: [
332+
{
333+
id: "pv_1",
334+
manage_inventory: true,
335+
allow_backorder: false,
336+
inventory_items: [
337+
{
338+
inventory_item_id: "ii_1",
339+
variant_id: "pv_1",
340+
required_quantity: 1,
341+
inventory: [
342+
{
343+
location_levels: {
344+
stocked_quantity: 0, // out of stock
345+
reserved_quantity: 0,
346+
location_id: "sl_1",
347+
stock_locations: [
348+
{
349+
id: "sl_1",
350+
sales_channels: [{ id: "sc_1" }],
351+
},
352+
],
353+
},
354+
},
355+
],
356+
},
357+
],
358+
},
359+
],
360+
items: [
361+
{
362+
variant_id: "pv_1",
363+
quantity: 1,
364+
id: "item_1",
365+
allow_backorder: true,
366+
},
367+
],
368+
}
369+
370+
const result = prepareConfirmInventoryInput({ input })
371+
372+
expect(result).toEqual({
373+
items: [
374+
{
375+
id: "item_1",
376+
inventory_item_id: "ii_1",
377+
required_quantity: 1,
378+
quantity: 1,
379+
allow_backorder: true,
380+
location_ids: ["sl_1"],
381+
},
382+
],
383+
})
384+
})
385+
386+
it("if an item opts into allow_backorder, it should return normally even if there's no stock location for the sales channel", () => {
387+
const input = {
388+
sales_channel_id: "sc_1",
389+
variants: [
390+
{
391+
id: "pv_1",
392+
manage_inventory: true,
393+
allow_backorder: false,
394+
inventory_items: [
395+
{
396+
inventory_item_id: "ii_1",
397+
variant_id: "pv_1",
398+
required_quantity: 1,
399+
inventory: [
400+
{
401+
location_levels: {
402+
stock_locations: [
403+
{
404+
id: "sl_2",
405+
sales_channels: [{ id: "sc_2" }], // Different sales channel
406+
},
407+
],
408+
},
409+
},
410+
],
411+
},
412+
],
413+
},
414+
],
415+
items: [
416+
{
417+
variant_id: "pv_1",
418+
quantity: 1,
419+
id: "item_1",
420+
allow_backorder: true,
421+
},
422+
],
423+
}
424+
425+
const result = prepareConfirmInventoryInput({ input })
426+
427+
expect(result).toEqual({
428+
items: [
429+
{
430+
id: "item_1",
431+
inventory_item_id: "ii_1",
432+
required_quantity: 1,
433+
quantity: 1,
434+
allow_backorder: true,
435+
location_ids: [],
436+
},
437+
],
438+
})
439+
})
440+
328441
it("should return only stock locations with availability, if any", () => {
329442
const input = {
330443
sales_channel_id: "sc_1",

packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ interface ConfirmInventoryPreparationInput {
5656
id?: string
5757
variant_id?: string | null
5858
quantity: BigNumberInput
59+
allow_backorder?: boolean
5960
}[]
6061
variants: {
6162
id: string
@@ -98,6 +99,15 @@ export const prepareConfirmInventoryInput = (data: {
9899

99100
const salesChannelId = data.input.sales_channel_id
100101

102+
// Variants for which an item explicitly opts into backorder, overriding the
103+
// variant's own `allow_backorder` setting (e.g. an admin adding an
104+
// out-of-stock item to a draft order).
105+
const itemBackorderVariantIds = new Set(
106+
(data.input.items ?? [])
107+
.filter((item) => item.allow_backorder && item.variant_id)
108+
.map((item) => item.variant_id as string)
109+
)
110+
101111
for (const updateItem of data.input.itemsToUpdate ?? []) {
102112
const updateItem_ = "data" in updateItem ? updateItem.data : updateItem
103113

@@ -190,7 +200,8 @@ export const prepareConfirmInventoryInput = (data: {
190200
if (
191201
variant.manage_inventory &&
192202
!variantsWithLocationForChannel.has(variant.id) &&
193-
!variant.allow_backorder
203+
!variant.allow_backorder &&
204+
!itemBackorderVariantIds.has(variant.id)
194205
) {
195206
throw new MedusaError(
196207
MedusaError.Types.INVALID_DATA,
@@ -269,7 +280,7 @@ const formatInventoryInput = ({
269280
id: item.id,
270281
inventory_item_id: variantInventoryItem.inventory_item_id,
271282
required_quantity: variantInventoryItem.required_quantity,
272-
allow_backorder: !!variant.allow_backorder,
283+
allow_backorder: !!variant.allow_backorder || !!item.allow_backorder,
273284
quantity: item.quantity,
274285
location_ids: locationsWithAvailability.length
275286
? locationsWithAvailability

packages/core/types/src/cart/workflows.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,11 @@ export interface ConfirmVariantInventoryWorkflowInputDTO {
510510
* The ID of the line item if it's already in the cart.
511511
*/
512512
id?: string
513+
/**
514+
* Whether the item can be added even if its variant is out of stock. This
515+
* overrides the variant's `allow_backorder` setting for this item only.
516+
*/
517+
allow_backorder?: boolean
513518
}[]
514519
/**
515520
* The new quantity of the variant to be added to the cart.

packages/core/types/src/workflow/order/items.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ interface NewItem {
2929
* A note viewed by admins only related to the item.
3030
*/
3131
internal_note?: string | null
32+
/**
33+
* Whether the item can be added even if its variant is out of stock. This
34+
* overrides the variant's `allow_backorder` setting for this item only.
35+
*/
36+
allow_backorder?: boolean
3237
/**
3338
* Custom key-value pairs to store additional information about the item.
3439
*/

0 commit comments

Comments
 (0)