From 3e2991e44719c2a829696215862ae5fbd368fe37 Mon Sep 17 00:00:00 2001 From: Nicolas Gorga <62995075+NicolasGorga@users.noreply.github.com> Date: Mon, 1 Dec 2025 09:08:03 -0300 Subject: [PATCH] fix(core-flows): create reservations on draft order conversion to regular order (#14010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .changeset/fancy-banks-move.md | 5 + .../draft-order/admin/draft-order.spec.ts | 199 +++++++++++++++--- .../workflows/confirm-draft-order-edit.ts | 108 +--------- .../workflows/convert-draft-order.ts | 52 ++++- 4 files changed, 231 insertions(+), 133 deletions(-) create mode 100644 .changeset/fancy-banks-move.md diff --git a/.changeset/fancy-banks-move.md b/.changeset/fancy-banks-move.md new file mode 100644 index 0000000000..cea1a9c7e7 --- /dev/null +++ b/.changeset/fancy-banks-move.md @@ -0,0 +1,5 @@ +--- +"@medusajs/core-flows": patch +--- + +fix(core-flows): create reservations on draft order conversion to regular order diff --git a/integration-tests/http/__tests__/draft-order/admin/draft-order.spec.ts b/integration-tests/http/__tests__/draft-order/admin/draft-order.spec.ts index 178b0fed00..4e419efc4c 100644 --- a/integration-tests/http/__tests__/draft-order/admin/draft-order.spec.ts +++ b/integration-tests/http/__tests__/draft-order/admin/draft-order.spec.ts @@ -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) }) }) diff --git a/packages/core/core-flows/src/draft-order/workflows/confirm-draft-order-edit.ts b/packages/core/core-flows/src/draft-order/workflows/confirm-draft-order-edit.ts index 749df1d975..1360bf4343 100644 --- a/packages/core/core-flows/src/draft-order/workflows/confirm-draft-order-edit.ts +++ b/packages/core/core-flows/src/draft-order/workflows/confirm-draft-order-edit.ts @@ -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, diff --git a/packages/core/core-flows/src/draft-order/workflows/convert-draft-order.ts b/packages/core/core-flows/src/draft-order/workflows/convert-draft-order.ts index b8758d3828..c7334616a2 100644 --- a/packages/core/core-flows/src/draft-order/workflows/convert-draft-order.ts +++ b/packages/core/core-flows/src/draft-order/workflows/convert-draft-order.ts @@ -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(