fix(core-flows): create reservations on draft order conversion to regular order (#14010)

## Summary

**What** — What changes are introduced in this PR?

Avoid creating reservations when draft order edits are confirmed and rather, create them when the draft order is converted into a regular order.

**Why** — Why are these changes relevant or necessary?  

While the order is a draft, creating reservations would potentially block inventory for regular order requests, when the draft represents a non materialized state of a purchase that might never be completed or at a latter point in time.

**How** — How have these changes been implemented?

Removed the reservation creations inside of `confirmDraftOrderEditWorkflow` and instead do it inside `convertDraftOrderWorkflow`

**Testing** — How have these changes been tested, or how can the reviewer test the feature?

Added integration tests.

---

## Examples

Provide examples or code snippets that demonstrate how this feature works, or how it can be used in practice.  
This helps with documentation and ensures maintainers can quickly understand and verify the change.

```ts
// Example usage
```

---

## Checklist

Please ensure the following before requesting a review:

- [x] I have added a **changeset** for this PR
    - Every non-breaking change should be marked as a **patch**
    - To add a changeset, run `yarn changeset` and follow the prompts
- [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

Add any additional context, related issues, or references that might help the reviewer understand this PR.

fixes #13773 
closes SUP-2523
This commit is contained in:
Nicolas Gorga
2025-12-01 09:08:03 -03:00
committed by GitHub
parent 73ae136965
commit 3e2991e447
4 changed files with 231 additions and 133 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/core-flows": patch
---
fix(core-flows): create reservations on draft order conversion to regular order

View File

@@ -1,7 +1,10 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { HttpTypes } from "@medusajs/types"
import { ModuleRegistrationName, ProductStatus } from "@medusajs/utils"
import { adminHeaders, createAdminUser, } from "../../../../helpers/create-admin-user"
import {
adminHeaders,
createAdminUser,
} from "../../../../helpers/create-admin-user"
import { setupTaxStructure } from "../../../../modules/__tests__/fixtures"
jest.setTimeout(300000)
@@ -238,6 +241,94 @@ medusaIntegrationTestRunner({
})
describe("POST /draft-orders/:id/convert-to-order", () => {
let product
let inventoryItemLarge
let inventoryItemMedium
beforeEach(async () => {
inventoryItemLarge = (
await api.post(
`/admin/inventory-items`,
{ sku: "shirt-large" },
adminHeaders
)
).data.inventory_item
inventoryItemMedium = (
await api.post(
`/admin/inventory-items`,
{ sku: "shirt-medium" },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/inventory-items/${inventoryItemLarge.id}/location-levels`,
{
location_id: stockLocation.id,
stocked_quantity: 10,
},
adminHeaders
)
await api.post(
`/admin/inventory-items/${inventoryItemMedium.id}/location-levels`,
{
location_id: stockLocation.id,
stocked_quantity: 10,
},
adminHeaders
)
product = (
await api.post(
"/admin/products",
{
title: "Shirt",
status: ProductStatus.PUBLISHED,
options: [{ title: "size", values: ["large", "medium"] }],
variants: [
{
title: "L shirt",
options: { size: "large" },
manage_inventory: true,
inventory_items: [
{
inventory_item_id: inventoryItemLarge.id,
required_quantity: 1,
},
],
prices: [
{
currency_code: "usd",
amount: 10,
},
],
},
{
title: "M shirt",
options: { size: "medium" },
manage_inventory: true,
inventory_items: [
{
inventory_item_id: inventoryItemMedium.id,
required_quantity: 1,
},
],
prices: [
{
currency_code: "usd",
amount: 10,
},
],
},
],
},
adminHeaders
)
).data.product
})
it("should convert a draft order to an order", async () => {
const response = await api.post(
`/admin/draft-orders/${testDraftOrder.id}/convert-to-order`,
@@ -248,6 +339,83 @@ medusaIntegrationTestRunner({
expect(response.status).toBe(200)
expect(response.data.order.status).toBe("pending")
})
it("should create reservations on draft order to order conversion", async () => {
await api.post(
`/admin/draft-orders/${testDraftOrder.id}/edit`,
{},
adminHeaders
)
await api.post(
`/admin/draft-orders/${testDraftOrder.id}/edit/items`,
{
items: [
{
variant_id: product.variants.find((v) => v.title === "L shirt")
.id,
quantity: 1,
},
],
},
adminHeaders
)
await api.post(
`/admin/draft-orders/${testDraftOrder.id}/edit/items`,
{
items: [
{
variant_id: product.variants.find((v) => v.title === "M shirt")
.id,
quantity: 1,
},
],
},
adminHeaders
)
let reservations = (await api.get(`/admin/reservations`, adminHeaders))
.data.reservations
expect(reservations.length).toBe(0)
await api.post(
`/admin/draft-orders/${testDraftOrder.id}/edit/confirm`,
{},
adminHeaders
)
reservations = (await api.get(`/admin/reservations`, adminHeaders)).data
.reservations
expect(reservations.length).toBe(0)
const response = await api.post(
`/admin/draft-orders/${testDraftOrder.id}/convert-to-order`,
{},
adminHeaders
)
reservations = (await api.get(`/admin/reservations`, adminHeaders)).data
.reservations
expect(reservations).toEqual(
expect.arrayContaining([
expect.objectContaining({
inventory_item_id: inventoryItemLarge.id,
quantity: 1,
}),
expect.objectContaining({
inventory_item_id: inventoryItemMedium.id,
quantity: 1,
}),
])
)
expect(response.status).toBe(200)
expect(response.data.order.status).toBe("pending")
})
})
describe("POST /draft-orders/:id/edit/items/:item_id", () => {
@@ -375,7 +543,7 @@ medusaIntegrationTestRunner({
).data.product
})
it("should manage reservations on order edit", async () => {
it("should not create reservations on draft order edit confirmation", async () => {
let reservations = (await api.get(`/admin/reservations`, adminHeaders))
.data.reservations
@@ -429,18 +597,7 @@ medusaIntegrationTestRunner({
reservations = (await api.get(`/admin/reservations`, adminHeaders)).data
.reservations
expect(reservations).toEqual(
expect.arrayContaining([
expect.objectContaining({
inventory_item_id: inventoryItemLarge.id,
quantity: 1,
}),
expect.objectContaining({
inventory_item_id: inventoryItemMedium.id,
quantity: 1,
}),
])
)
expect(reservations.length).toBe(0)
// Create second edit
edit = (
@@ -495,19 +652,7 @@ medusaIntegrationTestRunner({
reservations = (await api.get(`/admin/reservations`, adminHeaders)).data
.reservations
expect(reservations.length).toBe(2)
expect(reservations).toEqual(
expect.arrayContaining([
expect.objectContaining({
inventory_item_id: inventoryItemLarge.id,
quantity: 2,
}),
expect.objectContaining({
inventory_item_id: inventoryItemSmall.id,
quantity: 1,
}),
])
)
expect(reservations.length).toBe(0)
})
})

View File

@@ -1,15 +1,9 @@
import { ChangeActionType, MathBN, OrderChangeStatus, } from "@medusajs/framework/utils"
import { createWorkflow, transform, WorkflowResponse, } from "@medusajs/framework/workflows-sdk"
import { BigNumberInput, OrderChangeDTO, OrderDTO, } from "@medusajs/framework/types"
import { reserveInventoryStep } from "../../cart"
import {
prepareConfirmInventoryInput,
requiredOrderFieldsForInventoryConfirmation,
} from "../../cart/utils/prepare-confirm-inventory-input"
import { OrderChangeStatus, } from "@medusajs/framework/utils"
import { createWorkflow, WorkflowResponse, } from "@medusajs/framework/workflows-sdk"
import { OrderChangeDTO, OrderDTO, } from "@medusajs/framework/types"
import { useRemoteQueryStep } from "../../common"
import { createOrUpdateOrderPaymentCollectionWorkflow, previewOrderChangeStep, } from "../../order"
import { confirmOrderChanges } from "../../order/steps/confirm-order-changes"
import { deleteReservationsByLineItemsStep } from "../../reservation"
import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change"
import { acquireLockStep, releaseLockStep } from "../../locking"
@@ -111,102 +105,6 @@ export const confirmDraftOrderEditWorkflow = createWorkflow(
confirmed_by: input.confirmed_by,
})
const orderItems = useRemoteQueryStep({
entry_point: "order",
fields: requiredOrderFieldsForInventoryConfirmation,
variables: { id: input.order_id },
list: false,
throw_if_key_not_found: true,
}).config({ name: "order-items-query" })
const { variants, items, toRemoveReservationLineItemIds } = transform(
{ orderItems, previousOrderItems: order.items, orderPreview },
({ orderItems, previousOrderItems, orderPreview }) => {
const allItems: any[] = []
const allVariants: any[] = []
const previousItemIds = (previousOrderItems || []).map(({ id }) => id)
const currentItemIds = orderItems.items.map(({ id }) => id)
const removedItemIds = previousItemIds.filter(
(id) => !currentItemIds.includes(id)
)
const updatedItemIds: string[] = []
orderItems.items.forEach((ordItem) => {
const itemAction = orderPreview.items?.find(
(item) =>
item.id === ordItem.id &&
item.actions?.find(
(a) =>
a.action === ChangeActionType.ITEM_ADD ||
a.action === ChangeActionType.ITEM_UPDATE
)
)
if (!itemAction) {
return
}
const unitPrice: BigNumberInput =
itemAction.raw_unit_price ?? itemAction.unit_price
const compareAtUnitPrice: BigNumberInput | undefined =
itemAction.raw_compare_at_unit_price ??
itemAction.compare_at_unit_price
const updateAction = itemAction.actions!.find(
(a) => a.action === ChangeActionType.ITEM_UPDATE
)
if (updateAction) {
updatedItemIds.push(ordItem.id)
}
const newQuantity: BigNumberInput =
itemAction.raw_quantity ?? itemAction.quantity
const reservationQuantity = MathBN.sub(
newQuantity,
ordItem.raw_fulfilled_quantity
)
allItems.push({
id: ordItem.id,
variant_id: ordItem.variant_id,
quantity: reservationQuantity,
unit_price: unitPrice,
compare_at_unit_price: compareAtUnitPrice,
})
allVariants.push(ordItem.variant)
})
return {
variants: allVariants,
items: allItems,
toRemoveReservationLineItemIds: [
...removedItemIds,
...updatedItemIds,
],
}
}
)
const formatedInventoryItems = transform(
{
input: {
sales_channel_id: (orderItems as any).sales_channel_id,
variants,
items,
},
},
prepareConfirmInventoryInput
)
deleteReservationsByLineItemsStep(toRemoveReservationLineItemIds)
reserveInventoryStep(formatedInventoryItems)
createOrUpdateOrderPaymentCollectionWorkflow.runAsStep({
input: {
order_id: order.id,

View File

@@ -8,13 +8,23 @@ import {
createWorkflow,
parallelize,
StepResponse,
transform,
WorkflowData,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import type { IOrderModuleService, OrderDTO } from "@medusajs/framework/types"
import type {
ConfirmVariantInventoryWorkflowInputDTO,
IOrderModuleService,
OrderDTO,
} from "@medusajs/framework/types"
import { emitEventStep, useRemoteQueryStep } from "../../common"
import { validateDraftOrderStep } from "../steps/validate-draft-order"
import { acquireLockStep, releaseLockStep } from "../../locking"
import {
prepareConfirmInventoryInput,
requiredOrderFieldsForInventoryConfirmation,
} from "../../cart/utils/prepare-confirm-inventory-input"
import { reserveInventoryStep } from "../../cart"
export const convertDraftOrderWorkflowId = "convert-draft-order"
@@ -119,6 +129,46 @@ export const convertDraftOrderWorkflow = createWorkflow(
validateDraftOrderStep({ order })
const orderItems = useRemoteQueryStep({
entry_point: "order",
fields: requiredOrderFieldsForInventoryConfirmation,
variables: { id: input.id },
list: false,
throw_if_key_not_found: true,
}).config({ name: "order-items-query" })
const { variants, items } = transform({ orderItems }, ({ orderItems }) => {
const items: ConfirmVariantInventoryWorkflowInputDTO["items"] = []
const variants: ConfirmVariantInventoryWorkflowInputDTO["variants"] = []
for (const orderItem of orderItems.items ?? []) {
items.push({
variant_id: orderItem.variant.id,
quantity: orderItem.quantity,
id: orderItem.id,
})
variants.push(orderItem.variant)
}
return {
variants,
items,
}
})
const formatedInventoryItems = transform(
{
input: {
sales_channel_id: (orderItems as any).sales_channel_id,
variants,
items,
},
},
prepareConfirmInventoryInput
)
reserveInventoryStep(formatedInventoryItems)
const updatedOrder = convertDraftOrderStep({ id: input.id })
parallelize(