From af0140d317d2a8df18e62c55e2075661f8b53200 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Sun, 2 Jun 2024 09:33:24 -0300 Subject: [PATCH] feat(order): cancel fulfillment (#7573) --- .../workflows/create-fulfillment.spec.ts | 53 ++- .../src/common/steps/create-remote-links.ts | 6 +- .../src/order/steps/cancel-fulfillment.ts | 29 ++ .../workflows/cancel-order-fulfillment.ts | 148 +++++++++ .../src/order/workflows/create-fulfillment.ts | 56 ++-- .../src/order/workflows/create-return.ts | 4 +- .../core-flows/src/order/workflows/index.ts | 1 + .../src/product/workflows/update-products.ts | 14 +- packages/core/types/src/order/common.ts | 1 + packages/core/types/src/order/mutations.ts | 54 +-- packages/core/types/src/order/service.ts | 6 + .../src/workflow/order/cancel-fulfillment.ts | 5 + .../core/types/src/workflow/order/index.ts | 1 + .../[fulfillment_id]/cancel/route.ts | 18 +- .../medusa/src/api/admin/orders/validators.ts | 9 + .../__tests__/create-order.ts | 202 ------------ .../__tests__/order-return.ts | 308 ++++++++++++++++++ .../__tests__/util/actions/returns.ts | 2 +- .../src/services/order-module-service.ts | 34 ++ .../modules/order/src/utils/action-key.ts | 1 + .../utils/actions/cancel-item-fulfillment.ts | 74 +++++ .../order/src/utils/actions/cancel-return.ts | 2 +- .../order/src/utils/actions/fulfill-item.ts | 2 +- .../modules/order/src/utils/actions/index.ts | 1 + .../order/src/utils/actions/item-remove.ts | 2 +- .../actions/receive-damaged-return-item.ts | 2 +- .../src/utils/actions/receive-return-item.ts | 2 +- .../order/src/utils/actions/return-item.ts | 2 +- .../order/src/utils/actions/ship-item.ts | 2 +- .../order/src/utils/actions/write-off-item.ts | 2 +- 30 files changed, 745 insertions(+), 298 deletions(-) create mode 100644 packages/core/core-flows/src/order/steps/cancel-fulfillment.ts create mode 100644 packages/core/core-flows/src/order/workflows/cancel-order-fulfillment.ts create mode 100644 packages/core/types/src/workflow/order/cancel-fulfillment.ts create mode 100644 packages/modules/order/integration-tests/__tests__/order-return.ts create mode 100644 packages/modules/order/src/utils/actions/cancel-item-fulfillment.ts diff --git a/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts b/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts index 092010fbfa..fceed4d2c0 100644 --- a/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts +++ b/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts @@ -1,4 +1,5 @@ import { + cancelOrderFulfillmentWorkflow, createOrderFulfillmentWorkflow, createShippingOptionsWorkflow, } from "@medusajs/core-flows" @@ -314,7 +315,7 @@ medusaIntegrationTestRunner({ container = getContainer() }) - describe("Create order fulfillment workflow", () => { + describe("Order fulfillment workflow", () => { let shippingOption: ShippingOptionDTO let region: RegionDTO let location: StockLocationDTO @@ -335,9 +336,11 @@ medusaIntegrationTestRunner({ orderService = container.resolve(ModuleRegistrationName.ORDER) }) - it("should create a order fulfillment", async () => { + it("should create a order fulfillment and cancel it", async () => { const order = await createOrderFixture({ container, product, location }) - const createReturnOrderData: OrderWorkflow.CreateOrderFulfillmentWorkflowInput = + + // Create a fulfillment + const createOrderFulfillmentData: OrderWorkflow.CreateOrderFulfillmentWorkflowInput = { order_id: order.id, created_by: "user_1", @@ -352,7 +355,7 @@ medusaIntegrationTestRunner({ } await createOrderFulfillmentWorkflow(container).run({ - input: createReturnOrderData, + input: createOrderFulfillmentData, }) const remoteQuery = container.resolve( @@ -391,6 +394,48 @@ medusaIntegrationTestRunner({ [location.id] ) expect(stockAvailability).toEqual(1) + + // Cancel the fulfillment + const cancelFulfillmentData: OrderWorkflow.CancelOrderFulfillmentWorkflowInput = + { + order_id: order.id, + fulfillment_id: orderFulfill.fulfillments[0].id, + no_notification: false, + } + + await cancelOrderFulfillmentWorkflow(container).run({ + input: cancelFulfillmentData, + }) + + const remoteQueryObjectFulfill = remoteQueryObjectFromString({ + entryPoint: "order", + variables: { + id: order.id, + }, + fields: [ + "*", + "items.*", + "shipping_methods.*", + "total", + "item_total", + "fulfillments.*", + ], + }) + + const [orderFulfillAfterCancelled] = await remoteQuery( + remoteQueryObjectFulfill + ) + + expect(orderFulfillAfterCancelled.fulfillments).toHaveLength(1) + expect( + orderFulfillAfterCancelled.items[0].detail.fulfilled_quantity + ).toEqual(0) + + const stockAvailabilityAfterCancelled = + await inventoryModule.retrieveStockedQuantity(inventoryItem.id, [ + location.id, + ]) + expect(stockAvailabilityAfterCancelled).toEqual(2) }) }) }, diff --git a/packages/core/core-flows/src/common/steps/create-remote-links.ts b/packages/core/core-flows/src/common/steps/create-remote-links.ts index 08002cc391..6a5bf2c94a 100644 --- a/packages/core/core-flows/src/common/steps/create-remote-links.ts +++ b/packages/core/core-flows/src/common/steps/create-remote-links.ts @@ -1,12 +1,12 @@ import { LinkDefinition, RemoteLink } from "@medusajs/modules-sdk" -import { createStep, StepResponse } from "@medusajs/workflows-sdk" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" import { ContainerRegistrationKeys } from "@medusajs/utils" type CreateRemoteLinksStepInput = LinkDefinition[] -export const createLinksStepId = "create-links" -export const createLinkStep = createStep( +export const createLinksStepId = "create-remote-links" +export const createRemoteLinkStep = createStep( createLinksStepId, async (data: CreateRemoteLinksStepInput, { container }) => { const link = container.resolve( diff --git a/packages/core/core-flows/src/order/steps/cancel-fulfillment.ts b/packages/core/core-flows/src/order/steps/cancel-fulfillment.ts new file mode 100644 index 0000000000..84a696069b --- /dev/null +++ b/packages/core/core-flows/src/order/steps/cancel-fulfillment.ts @@ -0,0 +1,29 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { CancelOrderFulfillmentDTO, IOrderModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +type CancelOrderFulfillmentStepInput = CancelOrderFulfillmentDTO + +export const cancelOrderFulfillmentStepId = "cancel-order-fullfillment" +export const cancelOrderFulfillmentStep = createStep( + cancelOrderFulfillmentStepId, + async (data: CancelOrderFulfillmentStepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.ORDER + ) + + await service.cancelFulfillment(data) + return new StepResponse(void 0, data.order_id) + }, + async (orderId, { container }) => { + if (!orderId) { + return + } + + const service = container.resolve( + ModuleRegistrationName.ORDER + ) + + await service.revertLastVersion(orderId) + } +) diff --git a/packages/core/core-flows/src/order/workflows/cancel-order-fulfillment.ts b/packages/core/core-flows/src/order/workflows/cancel-order-fulfillment.ts new file mode 100644 index 0000000000..b9972d6305 --- /dev/null +++ b/packages/core/core-flows/src/order/workflows/cancel-order-fulfillment.ts @@ -0,0 +1,148 @@ +import { Modules } from "@medusajs/modules-sdk" +import { FulfillmentDTO, OrderDTO, OrderWorkflow } from "@medusajs/types" +import { MedusaError } from "@medusajs/utils" +import { + WorkflowData, + createStep, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" +import { useRemoteQueryStep } from "../../common" +import { cancelFulfillmentWorkflow } from "../../fulfillment" +import { adjustInventoryLevelsStep } from "../../inventory" +import { cancelOrderFulfillmentStep } from "../steps/cancel-fulfillment" +import { + throwIfItemsDoesNotExistsInOrder, + throwIfOrderIsCancelled, +} from "../utils/order-validation" + +const validateOrder = createStep( + "validate-order", + ({ + order, + input, + }: { + order: OrderDTO & { fulfillments: FulfillmentDTO[] } + input: OrderWorkflow.CancelOrderFulfillmentWorkflowInput + }) => { + throwIfOrderIsCancelled({ order }) + + const fulfillment = order.fulfillments.find( + (f) => f.id === input.fulfillment_id + ) + if (!fulfillment) { + throw new Error( + `Fulfillment with id ${input.fulfillment_id} not found in the order` + ) + } + + if (fulfillment.shipped_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `The fulfillment has already been shipped. Shipped fulfillments cannot be canceled` + ) + } + + throwIfItemsDoesNotExistsInOrder({ + order, + inputItems: fulfillment.items.map((i) => ({ + id: i.line_item_id as string, + quantity: i.quantity, + })), + }) + } +) + +function prepareCancelOrderFulfillmentData({ + order, + fulfillment, +}: { + order: OrderDTO + fulfillment: FulfillmentDTO +}) { + return { + order_id: order.id, + reference: Modules.FULFILLMENT, + reference_id: fulfillment.id, + items: fulfillment.items!.map((i) => { + return { + id: i.line_item_id as string, + quantity: i.quantity, + } + }), + } +} + +function prepareInventoryUpdate({ + fulfillment, +}: { + order: OrderDTO + fulfillment: FulfillmentDTO +}) { + const inventoryAdjustment: { + inventory_item_id: string + location_id: string + adjustment: number // TODO: BigNumberInput + }[] = [] + + for (const item of fulfillment.items) { + inventoryAdjustment.push({ + inventory_item_id: item.inventory_item_id as string, + location_id: fulfillment.location_id, + adjustment: item.quantity, + }) + } + + return { + inventoryAdjustment, + } +} + +export const cancelOrderFulfillmentWorkflowId = "cancel-order-fulfillment" +export const cancelOrderFulfillmentWorkflow = createWorkflow( + cancelOrderFulfillmentWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + const order: OrderDTO & { fulfillments: FulfillmentDTO[] } = + useRemoteQueryStep({ + entry_point: "orders", + fields: [ + "id", + "status", + "items.*", + "fulfillments.*", + "fulfillments.items.*", + ], + variables: { id: input.order_id }, + list: false, + throw_if_key_not_found: true, + }) + + validateOrder({ order, input }) + + const fulfillment = transform({ input, order }, ({ input, order }) => { + return order.fulfillments.find((f) => f.id === input.fulfillment_id)! + }) + + cancelFulfillmentWorkflow.runAsStep({ + input: { + id: input.fulfillment_id, + }, + }) + + const cancelOrderFulfillmentData = transform( + { order, fulfillment }, + prepareCancelOrderFulfillmentData + ) + + cancelOrderFulfillmentStep(cancelOrderFulfillmentData) + + const { inventoryAdjustment } = transform( + { order, fulfillment }, + prepareInventoryUpdate + ) + + adjustInventoryLevelsStep(inventoryAdjustment) + } +) diff --git a/packages/core/core-flows/src/order/workflows/create-fulfillment.ts b/packages/core/core-flows/src/order/workflows/create-fulfillment.ts index baf1a0f16a..ebd8234a0e 100644 --- a/packages/core/core-flows/src/order/workflows/create-fulfillment.ts +++ b/packages/core/core-flows/src/order/workflows/create-fulfillment.ts @@ -4,6 +4,7 @@ import { FulfillmentWorkflow, OrderDTO, OrderWorkflow, + ReservationItemDTO, } from "@medusajs/types" import { MedusaError } from "@medusajs/utils" import { @@ -13,7 +14,7 @@ import { parallelize, transform, } from "@medusajs/workflows-sdk" -import { createLinkStep, useRemoteQueryStep } from "../../common" +import { createRemoteLinkStep, useRemoteQueryStep } from "../../common" import { createFulfillmentWorkflow } from "../../fulfillment" import { adjustInventoryLevelsStep } from "../../inventory" import { @@ -70,6 +71,7 @@ function prepareFulfillmentData({ order, input, shippingOption, + reservations, }: { order: OrderDTO input: OrderWorkflow.CreateOrderFulfillmentWorkflowInput @@ -78,15 +80,21 @@ function prepareFulfillmentData({ provider_id: string service_zone: { fulfillment_set: { location?: { id: string } } } } + reservations: ReservationItemDTO[] }) { const inputItems = input.items const orderItemsMap = new Map["items"][0]>( order.items!.map((i) => [i.id, i]) ) + const reservationItemMap = new Map( + reservations.map((r) => [r.line_item_id as string, r]) + ) const fulfillmentItems = inputItems.map((i) => { const orderItem = orderItemsMap.get(i.id)! + const reservation = reservationItemMap.get(i.id)! return { line_item_id: i.id, + inventory_item_id: reservation.inventory_item_id, quantity: i.quantity, title: orderItem.variant_title ?? orderItem.title, sku: orderItem.variant_sku || "", @@ -118,7 +126,7 @@ function prepareFulfillmentData({ } } -function prepareInventoryReservations({ reservations, order, input }) { +function prepareInventoryUpdate({ reservations, order, input }) { if (!reservations || !reservations.length) { throw new Error( `No stock reservation found for items ${input.items.map((i) => i.id)}` @@ -215,8 +223,27 @@ export const createOrderFulfillmentWorkflow = createWorkflow( throw_if_key_not_found: true, }).config({ name: "get-shipping-option" }) + const lineItemIds = transform({ order }, ({ order }) => { + return order.items?.map((i) => i.id) + }) + const reservations = useRemoteQueryStep({ + entry_point: "reservations", + fields: [ + "id", + "line_item_id", + "quantity", + "inventory_item_id", + "location_id", + ], + variables: { + filter: { + line_item_id: lineItemIds, + }, + }, + }).config({ name: "get-reservations" }) + const fulfillmentData = transform( - { order, input, shippingOption }, + { order, input, shippingOption, reservations }, prepareFulfillmentData ) @@ -240,30 +267,11 @@ export const createOrderFulfillmentWorkflow = createWorkflow( ] } ) - createLinkStep(link) - - const lineItemIds = transform({ order }, ({ order }) => { - return order.items?.map((i) => i.id) - }) - const reservations = useRemoteQueryStep({ - entry_point: "reservations", - fields: [ - "id", - "line_item_id", - "quantity", - "inventory_item_id", - "location_id", - ], - variables: { - filter: { - line_item_id: lineItemIds, - }, - }, - }).config({ name: "get-reservations" }) + createRemoteLinkStep(link) const { toDelete, toUpdate, inventoryAdjustment } = transform( { order, reservations, input }, - prepareInventoryReservations + prepareInventoryUpdate ) parallelize( diff --git a/packages/core/core-flows/src/order/workflows/create-return.ts b/packages/core/core-flows/src/order/workflows/create-return.ts index 5f90213b2d..fc75fe272d 100644 --- a/packages/core/core-flows/src/order/workflows/create-return.ts +++ b/packages/core/core-flows/src/order/workflows/create-return.ts @@ -21,7 +21,7 @@ import { createWorkflow, transform, } from "@medusajs/workflows-sdk" -import { createLinkStep, useRemoteQueryStep } from "../../common" +import { createRemoteLinkStep, useRemoteQueryStep } from "../../common" import { createReturnFulfillmentWorkflow } from "../../fulfillment" import { updateOrderTaxLinesStep } from "../steps" import { createReturnStep } from "../steps/create-return" @@ -326,6 +326,6 @@ export const createReturnOrderWorkflow = createWorkflow( ] } ) - createLinkStep(link) + createRemoteLinkStep(link) } ) diff --git a/packages/core/core-flows/src/order/workflows/index.ts b/packages/core/core-flows/src/order/workflows/index.ts index 7ed9e0d836..0d4afa98fc 100644 --- a/packages/core/core-flows/src/order/workflows/index.ts +++ b/packages/core/core-flows/src/order/workflows/index.ts @@ -1,4 +1,5 @@ export * from "./archive-orders" +export * from "./cancel-order-fulfillment" export * from "./complete-orders" export * from "./create-fulfillment" export * from "./create-orders" diff --git a/packages/core/core-flows/src/product/workflows/update-products.ts b/packages/core/core-flows/src/product/workflows/update-products.ts index 9f1a587c82..1ca1b45fe1 100644 --- a/packages/core/core-flows/src/product/workflows/update-products.ts +++ b/packages/core/core-flows/src/product/workflows/update-products.ts @@ -1,18 +1,18 @@ import { updateProductsStep } from "../steps/update-products" -import { - dismissRemoteLinkStep, - createLinkStep, - useRemoteQueryStep, -} from "../../common" -import { arrayDifference } from "@medusajs/utils" import { Modules } from "@medusajs/modules-sdk" import { ProductTypes } from "@medusajs/types" +import { arrayDifference } from "@medusajs/utils" import { WorkflowData, createWorkflow, transform, } from "@medusajs/workflows-sdk" +import { + createRemoteLinkStep, + dismissRemoteLinkStep, + useRemoteQueryStep, +} from "../../common" type UpdateProductsStepInputSelector = { selector: ProductTypes.FilterableProductProps @@ -161,7 +161,7 @@ export const updateProductsWorkflow = createWorkflow( prepareSalesChannelLinks ) - createLinkStep(salesChannelLinks) + createRemoteLinkStep(salesChannelLinks) return updatedProducts } diff --git a/packages/core/types/src/order/common.ts b/packages/core/types/src/order/common.ts index fb57302366..fa85a6dbbc 100644 --- a/packages/core/types/src/order/common.ts +++ b/packages/core/types/src/order/common.ts @@ -8,6 +8,7 @@ export type ChangeActionType = | "CANCEL" | "CANCEL_RETURN" | "FULFILL_ITEM" + | "CANCEL_ITEM_FULFILLMENT" | "ITEM_ADD" | "ITEM_REMOVE" | "RECEIVE_DAMAGED_RETURN_ITEM" diff --git a/packages/core/types/src/order/mutations.ts b/packages/core/types/src/order/mutations.ts index 4e881833c3..b4d2ee6312 100644 --- a/packages/core/types/src/order/mutations.ts +++ b/packages/core/types/src/order/mutations.ts @@ -368,7 +368,7 @@ export interface UpdateOrderItemWithSelectorDTO { /** ORDER bundled action flows */ -export interface RegisterOrderFulfillmentDTO { +interface BaseOrderBundledActionsDTO { order_id: string description?: string internal_note?: string @@ -384,54 +384,18 @@ export interface RegisterOrderFulfillmentDTO { metadata?: Record | null } -export interface RegisterOrderShipmentDTO { - order_id: string - description?: string - internal_note?: string - reference?: string - reference_id?: string - created_by?: string - items: { - id: string - quantity: BigNumberInput - internal_note?: string - metadata?: Record | null - }[] - metadata?: Record | null -} +export interface RegisterOrderFulfillmentDTO + extends BaseOrderBundledActionsDTO {} -export interface CreateOrderReturnDTO { - order_id: string - description?: string - reference?: string - reference_id?: string - internal_note?: string - created_by?: string +export interface CancelOrderFulfillmentDTO extends BaseOrderBundledActionsDTO {} + +export interface RegisterOrderShipmentDTO extends BaseOrderBundledActionsDTO {} + +export interface CreateOrderReturnDTO extends BaseOrderBundledActionsDTO { shipping_method: Omit | string - items: { - id: string - quantity: BigNumberInput - internal_note?: string - metadata?: Record | null - }[] - metadata?: Record | null } -export interface ReceiveOrderReturnDTO { - order_id: string - description?: string - internal_note?: string - reference?: string - reference_id?: string - created_by?: string - items: { - id: string - quantity: BigNumberInput - internal_note?: string - metadata?: Record | null - }[] - metadata?: Record | null -} +export interface ReceiveOrderReturnDTO extends BaseOrderBundledActionsDTO {} /** ORDER bundled action flows */ diff --git a/packages/core/types/src/order/service.ts b/packages/core/types/src/order/service.ts index 3d0a8ed6df..bead9ab8e2 100644 --- a/packages/core/types/src/order/service.ts +++ b/packages/core/types/src/order/service.ts @@ -29,6 +29,7 @@ import { } from "./common" import { CancelOrderChangeDTO, + CancelOrderFulfillmentDTO, ConfirmOrderChangeDTO, CreateOrderAddressDTO, CreateOrderAdjustmentDTO, @@ -1496,6 +1497,11 @@ export interface IOrderModuleService extends IModuleService { sharedContext?: Context ): Promise + cancelFulfillment( + data: CancelOrderFulfillmentDTO, + sharedContext?: Context + ): Promise + registerShipment( data: RegisterOrderShipmentDTO, sharedContext?: Context diff --git a/packages/core/types/src/workflow/order/cancel-fulfillment.ts b/packages/core/types/src/workflow/order/cancel-fulfillment.ts new file mode 100644 index 0000000000..cf7850747f --- /dev/null +++ b/packages/core/types/src/workflow/order/cancel-fulfillment.ts @@ -0,0 +1,5 @@ +export interface CancelOrderFulfillmentWorkflowInput { + order_id: string + fulfillment_id: string + no_notification?: boolean +} diff --git a/packages/core/types/src/workflow/order/index.ts b/packages/core/types/src/workflow/order/index.ts index 9a2fb3a6ce..26af063cf1 100644 --- a/packages/core/types/src/workflow/order/index.ts +++ b/packages/core/types/src/workflow/order/index.ts @@ -1,3 +1,4 @@ +export * from "./cancel-fulfillment" export * from "./create-fulfillment" export * from "./create-return-order" export * from "./create-shipment" diff --git a/packages/medusa/src/api/admin/orders/[id]/fulfillments/[fulfillment_id]/cancel/route.ts b/packages/medusa/src/api/admin/orders/[id]/fulfillments/[fulfillment_id]/cancel/route.ts index 2079e093d7..c494393cfc 100644 --- a/packages/medusa/src/api/admin/orders/[id]/fulfillments/[fulfillment_id]/cancel/route.ts +++ b/packages/medusa/src/api/admin/orders/[id]/fulfillments/[fulfillment_id]/cancel/route.ts @@ -1,3 +1,4 @@ +import { cancelOrderFulfillmentWorkflow } from "@medusajs/core-flows" import { ContainerRegistrationKeys, remoteQueryObjectFromString, @@ -6,16 +7,29 @@ import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../../../../../types/routing" +import { AdminOrderCancelFulfillmentType } from "../../../../validators" export const POST = async ( - req: AuthenticatedMedusaRequest, + req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) const variables = { id: req.params.id } - // TODO: Workflow to cancel fulfillment + adjust inventory + const input = { + ...req.validatedBody, + order_id: req.params.id, + } + + const { errors } = await cancelOrderFulfillmentWorkflow(req.scope).run({ + input, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } const queryObject = remoteQueryObjectFromString({ entryPoint: "order", diff --git a/packages/medusa/src/api/admin/orders/validators.ts b/packages/medusa/src/api/admin/orders/validators.ts index 57fe6c8018..2a85125c47 100644 --- a/packages/medusa/src/api/admin/orders/validators.ts +++ b/packages/medusa/src/api/admin/orders/validators.ts @@ -79,3 +79,12 @@ export const AdminOrderCreateShipment = z.object({ export type AdminOrderCreateShipmentType = z.infer< typeof AdminOrderCreateShipment > + +export const AdminOrderCancelFulfillment = z.object({ + fulfillment_id: z.string(), + no_notification: z.boolean().optional(), +}) + +export type AdminOrderCancelFulfillmentType = z.infer< + typeof AdminOrderCancelFulfillment +> diff --git a/packages/modules/order/integration-tests/__tests__/create-order.ts b/packages/modules/order/integration-tests/__tests__/create-order.ts index fa67752b4a..33c146afb1 100644 --- a/packages/modules/order/integration-tests/__tests__/create-order.ts +++ b/packages/modules/order/integration-tests/__tests__/create-order.ts @@ -418,208 +418,6 @@ moduleIntegrationTestRunner({ ) expect(orders4.length).toEqual(0) }) - - it("should create an order, fulfill, ship and return the items", async function () { - const createdOrder = await service.create(input) - - // Fullfilment - await service.registerFulfillment({ - order_id: createdOrder.id, - items: createdOrder.items!.map((item) => { - return { - id: item.id, - quantity: item.quantity, - } - }), - }) - - let getOrder = await service.retrieve(createdOrder.id, { - select: [ - "id", - "version", - "items.id", - "items.quantity", - "items.detail.id", - "items.detail.version", - "items.detail.quantity", - "items.detail.shipped_quantity", - "items.detail.fulfilled_quantity", - ], - relations: ["items", "items.detail"], - }) - - let serializedOrder = JSON.parse(JSON.stringify(getOrder)) - - expect(serializedOrder).toEqual( - expect.objectContaining({ - version: 2, - items: [ - expect.objectContaining({ - quantity: 1, - detail: expect.objectContaining({ - version: 2, - quantity: 1, - fulfilled_quantity: 1, - shipped_quantity: 0, - }), - }), - expect.objectContaining({ - quantity: 2, - detail: expect.objectContaining({ - version: 2, - quantity: 2, - fulfilled_quantity: 2, - shipped_quantity: 0, - }), - }), - expect.objectContaining({ - quantity: 1, - detail: expect.objectContaining({ - version: 2, - quantity: 1, - fulfilled_quantity: 1, - shipped_quantity: 0, - }), - }), - ], - }) - ) - - // Shipment - await service.registerShipment({ - order_id: createdOrder.id, - reference: Modules.FULFILLMENT, - items: createdOrder.items!.map((item) => { - return { - id: item.id, - quantity: item.quantity, - } - }), - }) - - getOrder = await service.retrieve(createdOrder.id, { - select: [ - "id", - "version", - "items.id", - "items.quantity", - "items.detail.id", - "items.detail.version", - "items.detail.quantity", - "items.detail.shipped_quantity", - "items.detail.fulfilled_quantity", - ], - relations: ["items", "items.detail"], - }) - - serializedOrder = JSON.parse(JSON.stringify(getOrder)) - - expect(serializedOrder).toEqual( - expect.objectContaining({ - version: 3, - items: [ - expect.objectContaining({ - quantity: 1, - detail: expect.objectContaining({ - version: 3, - quantity: 1, - fulfilled_quantity: 1, - shipped_quantity: 1, - }), - }), - expect.objectContaining({ - quantity: 2, - detail: expect.objectContaining({ - version: 3, - quantity: 2, - fulfilled_quantity: 2, - shipped_quantity: 2, - }), - }), - expect.objectContaining({ - quantity: 1, - detail: expect.objectContaining({ - version: 3, - quantity: 1, - fulfilled_quantity: 1, - shipped_quantity: 1, - }), - }), - ], - }) - ) - - // Return - await service.createReturn({ - order_id: createdOrder.id, - reference: Modules.FULFILLMENT, - description: "Return all the items", - internal_note: "user wants to return all items", - shipping_method: createdOrder.shipping_methods![0].id, - items: createdOrder.items!.map((item) => { - return { - id: item.id, - quantity: item.quantity, - } - }), - }) - - getOrder = await service.retrieve(createdOrder.id, { - select: [ - "id", - "version", - "items.id", - "items.quantity", - "items.detail.id", - "items.detail.version", - "items.detail.quantity", - "items.detail.shipped_quantity", - "items.detail.fulfilled_quantity", - "items.detail.return_requested_quantity", - ], - relations: ["items", "items.detail"], - }) - - serializedOrder = JSON.parse(JSON.stringify(getOrder)) - - expect(serializedOrder).toEqual( - expect.objectContaining({ - version: 4, - items: [ - expect.objectContaining({ - quantity: 1, - detail: expect.objectContaining({ - version: 4, - quantity: 1, - fulfilled_quantity: 1, - shipped_quantity: 1, - return_requested_quantity: 1, - }), - }), - expect.objectContaining({ - quantity: 2, - detail: expect.objectContaining({ - version: 4, - quantity: 2, - fulfilled_quantity: 2, - shipped_quantity: 2, - return_requested_quantity: 2, - }), - }), - expect.objectContaining({ - quantity: 1, - detail: expect.objectContaining({ - version: 4, - quantity: 1, - fulfilled_quantity: 1, - shipped_quantity: 1, - return_requested_quantity: 1, - }), - }), - ], - }) - ) - }) }) }, }) diff --git a/packages/modules/order/integration-tests/__tests__/order-return.ts b/packages/modules/order/integration-tests/__tests__/order-return.ts new file mode 100644 index 0000000000..5057e09438 --- /dev/null +++ b/packages/modules/order/integration-tests/__tests__/order-return.ts @@ -0,0 +1,308 @@ +import { Modules } from "@medusajs/modules-sdk" +import { CreateOrderDTO, IOrderModuleService } from "@medusajs/types" +import { SuiteOptions, moduleIntegrationTestRunner } from "medusa-test-utils" + +jest.setTimeout(100000) + +moduleIntegrationTestRunner({ + moduleName: Modules.ORDER, + testSuite: ({ service }: SuiteOptions) => { + describe("Order Module Service - Return flows", () => { + const input = { + email: "foo@bar.com", + items: [ + { + title: "Item 1", + subtitle: "Subtitle 1", + thumbnail: "thumbnail1.jpg", + quantity: 1, + product_id: "product1", + product_title: "Product 1", + product_description: "Description 1", + product_subtitle: "Product Subtitle 1", + product_type: "Type 1", + product_collection: "Collection 1", + product_handle: "handle1", + variant_id: "variant1", + variant_sku: "SKU1", + variant_barcode: "Barcode1", + variant_title: "Variant 1", + variant_option_values: { + color: "Red", + size: "Large", + }, + requires_shipping: true, + is_discountable: true, + is_tax_inclusive: true, + compare_at_unit_price: 10, + unit_price: 8, + tax_lines: [ + { + description: "Tax 1", + tax_rate_id: "tax_usa", + code: "code", + rate: 0.1, + provider_id: "taxify_master", + }, + ], + adjustments: [ + { + code: "VIP_10", + amount: 10, + description: "VIP discount", + promotion_id: "prom_123", + provider_id: "coupon_kings", + }, + ], + }, + { + title: "Item 2", + quantity: 2, + unit_price: 5, + }, + { + title: "Item 3", + quantity: 1, + unit_price: 30, + }, + ], + sales_channel_id: "test", + shipping_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + }, + shipping_methods: [ + { + name: "Test shipping method", + amount: 10, + }, + ], + transactions: [ + { + amount: 58, + currency_code: "USD", + reference: "payment", + reference_id: "pay_123", + }, + ], + currency_code: "usd", + customer_id: "joe", + } as CreateOrderDTO + + it("should create an order, fulfill, ship and return the items and cancel some item return", async function () { + const createdOrder = await service.create(input) + + // Fullfilment + await service.registerFulfillment({ + order_id: createdOrder.id, + items: createdOrder.items!.map((item) => { + return { + id: item.id, + quantity: item.quantity, + } + }), + }) + + let getOrder = await service.retrieve(createdOrder.id, { + select: [ + "id", + "version", + "items.id", + "items.quantity", + "items.detail.id", + "items.detail.version", + "items.detail.quantity", + "items.detail.shipped_quantity", + "items.detail.fulfilled_quantity", + ], + relations: ["items", "items.detail"], + }) + + let serializedOrder = JSON.parse(JSON.stringify(getOrder)) + + expect(serializedOrder).toEqual( + expect.objectContaining({ + version: 2, + items: [ + expect.objectContaining({ + quantity: 1, + detail: expect.objectContaining({ + version: 2, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 0, + }), + }), + expect.objectContaining({ + quantity: 2, + detail: expect.objectContaining({ + version: 2, + quantity: 2, + fulfilled_quantity: 2, + shipped_quantity: 0, + }), + }), + expect.objectContaining({ + quantity: 1, + detail: expect.objectContaining({ + version: 2, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 0, + }), + }), + ], + }) + ) + + // Shipment + await service.registerShipment({ + order_id: createdOrder.id, + reference: Modules.FULFILLMENT, + items: createdOrder.items!.map((item) => { + return { + id: item.id, + quantity: item.quantity, + } + }), + }) + + getOrder = await service.retrieve(createdOrder.id, { + select: [ + "id", + "version", + "items.id", + "items.quantity", + "items.detail.id", + "items.detail.version", + "items.detail.quantity", + "items.detail.shipped_quantity", + "items.detail.fulfilled_quantity", + ], + relations: ["items", "items.detail"], + }) + + serializedOrder = JSON.parse(JSON.stringify(getOrder)) + + expect(serializedOrder).toEqual( + expect.objectContaining({ + version: 3, + items: [ + expect.objectContaining({ + quantity: 1, + detail: expect.objectContaining({ + version: 3, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + }), + }), + expect.objectContaining({ + quantity: 2, + detail: expect.objectContaining({ + version: 3, + quantity: 2, + fulfilled_quantity: 2, + shipped_quantity: 2, + }), + }), + expect.objectContaining({ + quantity: 1, + detail: expect.objectContaining({ + version: 3, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + }), + }), + ], + }) + ) + + // Return + await service.createReturn({ + order_id: createdOrder.id, + reference: Modules.FULFILLMENT, + description: "Return all the items", + internal_note: "user wants to return all items", + shipping_method: createdOrder.shipping_methods![0].id, + items: createdOrder.items!.map((item) => { + return { + id: item.id, + quantity: item.quantity, + } + }), + }) + + getOrder = await service.retrieve(createdOrder.id, { + select: [ + "id", + "version", + "items.id", + "items.quantity", + "items.detail.id", + "items.detail.version", + "items.detail.quantity", + "items.detail.shipped_quantity", + "items.detail.fulfilled_quantity", + "items.detail.return_requested_quantity", + ], + relations: ["items", "items.detail"], + }) + + serializedOrder = JSON.parse(JSON.stringify(getOrder)) + + expect(serializedOrder).toEqual( + expect.objectContaining({ + version: 4, + items: [ + expect.objectContaining({ + quantity: 1, + detail: expect.objectContaining({ + version: 4, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + return_requested_quantity: 1, + }), + }), + expect.objectContaining({ + quantity: 2, + detail: expect.objectContaining({ + version: 4, + quantity: 2, + fulfilled_quantity: 2, + shipped_quantity: 2, + return_requested_quantity: 2, + }), + }), + expect.objectContaining({ + quantity: 1, + detail: expect.objectContaining({ + version: 4, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + return_requested_quantity: 1, + }), + }), + ], + }) + ) + }) + }) + }, +}) diff --git a/packages/modules/order/src/services/__tests__/util/actions/returns.ts b/packages/modules/order/src/services/__tests__/util/actions/returns.ts index 5e43d6c9f0..e7ec6fd7a1 100644 --- a/packages/modules/order/src/services/__tests__/util/actions/returns.ts +++ b/packages/modules/order/src/services/__tests__/util/actions/returns.ts @@ -95,7 +95,7 @@ describe("Order Return - Actions", function () { order: originalOrder, actions, }) - }).toThrow(`Reference ID "333" not found.`) + }).toThrow(`Item ID "333" not found.`) }) it("should validate return received", function () { diff --git a/packages/modules/order/src/services/order-module-service.ts b/packages/modules/order/src/services/order-module-service.ts index ff1b8c5426..0515846b25 100644 --- a/packages/modules/order/src/services/order-module-service.ts +++ b/packages/modules/order/src/services/order-module-service.ts @@ -2142,6 +2142,40 @@ export default class OrderModuleService< await this.confirmOrderChange(change[0].id, sharedContext) } + @InjectTransactionManager("baseRepository_") + async cancelFulfillment( + data: OrderTypes.CancelOrderFulfillmentDTO, + sharedContext?: Context + ): Promise { + const items = data.items.map((item) => { + return { + action: ChangeActionType.CANCEL_ITEM_FULFILLMENT, + internal_note: item.internal_note, + reference: data.reference, + reference_id: data.reference_id, + details: { + reference_id: item.id, + quantity: item.quantity, + metadata: item.metadata, + }, + } + }) + + const change = await this.createOrderChange_( + { + order_id: data.order_id, + description: data.description, + internal_note: data.internal_note, + created_by: data.created_by, + metadata: data.metadata, + actions: items, + }, + sharedContext + ) + + await this.confirmOrderChange(change[0].id, sharedContext) + } + @InjectTransactionManager("baseRepository_") async registerShipment( data: OrderTypes.RegisterOrderShipmentDTO, diff --git a/packages/modules/order/src/utils/action-key.ts b/packages/modules/order/src/utils/action-key.ts index f2f7b03f94..02e5e5426e 100644 --- a/packages/modules/order/src/utils/action-key.ts +++ b/packages/modules/order/src/utils/action-key.ts @@ -2,6 +2,7 @@ export enum ChangeActionType { CANCEL = "CANCEL", CANCEL_RETURN = "CANCEL_RETURN", FULFILL_ITEM = "FULFILL_ITEM", + CANCEL_ITEM_FULFILLMENT = "CANCEL_ITEM_FULFILLMENT", ITEM_ADD = "ITEM_ADD", ITEM_REMOVE = "ITEM_REMOVE", RECEIVE_DAMAGED_RETURN_ITEM = "RECEIVE_DAMAGED_RETURN_ITEM", diff --git a/packages/modules/order/src/utils/actions/cancel-item-fulfillment.ts b/packages/modules/order/src/utils/actions/cancel-item-fulfillment.ts new file mode 100644 index 0000000000..ad26aed5f0 --- /dev/null +++ b/packages/modules/order/src/utils/actions/cancel-item-fulfillment.ts @@ -0,0 +1,74 @@ +import { MathBN, MedusaError, isDefined } from "@medusajs/utils" +import { ChangeActionType } from "../action-key" +import { OrderChangeProcessing } from "../calculate-order-change" + +OrderChangeProcessing.registerActionType( + ChangeActionType.CANCEL_ITEM_FULFILLMENT, + { + operation({ action, currentOrder }) { + const existing = currentOrder.items.find( + (item) => item.id === action.details.reference_id + )! + + existing.detail.fulfilled_quantity ??= 0 + + existing.detail.fulfilled_quantity = MathBN.sub( + existing.detail.fulfilled_quantity, + action.details.quantity + ) + }, + revert({ action, currentOrder }) { + const existing = currentOrder.items.find( + (item) => item.id === action.reference_id + )! + + existing.detail.fulfilled_quantity = MathBN.add( + existing.detail.fulfilled_quantity, + action.details.quantity + ) + }, + validate({ action, currentOrder }) { + const refId = action.details?.reference_id + if (!isDefined(refId)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Reference ID is required." + ) + } + + const existing = currentOrder.items.find((item) => item.id === refId) + + if (!existing) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Item ID "${refId}" not found.` + ) + } + + if (!action.details?.quantity) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Quantity to cancel item fulfillment ${refId} is required.` + ) + } + + if (action.details?.quantity < 1) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Quantity to cancel item ${refId} must be greater than 0.` + ) + } + + const greater = MathBN.gt( + action.details?.quantity, + existing.detail?.fulfilled_quantity + ) + if (greater) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot cancel more items than what was fulfilled for item ${refId}.` + ) + } + }, + } +) diff --git a/packages/modules/order/src/utils/actions/cancel-return.ts b/packages/modules/order/src/utils/actions/cancel-return.ts index 20d1a47e95..ee97328a4c 100644 --- a/packages/modules/order/src/utils/actions/cancel-return.ts +++ b/packages/modules/order/src/utils/actions/cancel-return.ts @@ -48,7 +48,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.CANCEL_RETURN, { if (!existing) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `Reference ID "${refId}" not found.` + `Item ID "${refId}" not found.` ) } diff --git a/packages/modules/order/src/utils/actions/fulfill-item.ts b/packages/modules/order/src/utils/actions/fulfill-item.ts index 693f0ce830..07e79c0021 100644 --- a/packages/modules/order/src/utils/actions/fulfill-item.ts +++ b/packages/modules/order/src/utils/actions/fulfill-item.ts @@ -38,7 +38,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.FULFILL_ITEM, { if (!existing) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `Reference ID "${refId}" not found.` + `Item ID "${refId}" not found.` ) } diff --git a/packages/modules/order/src/utils/actions/index.ts b/packages/modules/order/src/utils/actions/index.ts index ff28c4a921..082c41dfac 100644 --- a/packages/modules/order/src/utils/actions/index.ts +++ b/packages/modules/order/src/utils/actions/index.ts @@ -1,4 +1,5 @@ export * from "./cancel" +export * from "./cancel-item-fulfillment" export * from "./cancel-return" export * from "./fulfill-item" export * from "./item-add" diff --git a/packages/modules/order/src/utils/actions/item-remove.ts b/packages/modules/order/src/utils/actions/item-remove.ts index 17bfe416b2..f7ac47b90a 100644 --- a/packages/modules/order/src/utils/actions/item-remove.ts +++ b/packages/modules/order/src/utils/actions/item-remove.ts @@ -58,7 +58,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_REMOVE, { if (!existing) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `Reference ID "${refId}" not found.` + `Item ID "${refId}" not found.` ) } diff --git a/packages/modules/order/src/utils/actions/receive-damaged-return-item.ts b/packages/modules/order/src/utils/actions/receive-damaged-return-item.ts index 636cba24b5..3c55f0091b 100644 --- a/packages/modules/order/src/utils/actions/receive-damaged-return-item.ts +++ b/packages/modules/order/src/utils/actions/receive-damaged-return-item.ts @@ -89,7 +89,7 @@ OrderChangeProcessing.registerActionType( if (!existing) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `Reference ID "${refId}" not found.` + `Item ID "${refId}" not found.` ) } diff --git a/packages/modules/order/src/utils/actions/receive-return-item.ts b/packages/modules/order/src/utils/actions/receive-return-item.ts index feb6aac04f..c1694dbf80 100644 --- a/packages/modules/order/src/utils/actions/receive-return-item.ts +++ b/packages/modules/order/src/utils/actions/receive-return-item.ts @@ -95,7 +95,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.RECEIVE_RETURN_ITEM, { if (!existing) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `Reference ID "${refId}" not found.` + `Item ID "${refId}" not found.` ) } diff --git a/packages/modules/order/src/utils/actions/return-item.ts b/packages/modules/order/src/utils/actions/return-item.ts index f66e207826..a5d1be4c83 100644 --- a/packages/modules/order/src/utils/actions/return-item.ts +++ b/packages/modules/order/src/utils/actions/return-item.ts @@ -42,7 +42,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.RETURN_ITEM, { if (!existing) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `Reference ID "${refId}" not found.` + `Item ID "${refId}" not found.` ) } diff --git a/packages/modules/order/src/utils/actions/ship-item.ts b/packages/modules/order/src/utils/actions/ship-item.ts index 0465fa511a..f58001f115 100644 --- a/packages/modules/order/src/utils/actions/ship-item.ts +++ b/packages/modules/order/src/utils/actions/ship-item.ts @@ -38,7 +38,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.SHIP_ITEM, { if (!existing) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `Reference ID "${refId}" not found.` + `Item ID "${refId}" not found.` ) } diff --git a/packages/modules/order/src/utils/actions/write-off-item.ts b/packages/modules/order/src/utils/actions/write-off-item.ts index fd48bd8a85..3259e6b15d 100644 --- a/packages/modules/order/src/utils/actions/write-off-item.ts +++ b/packages/modules/order/src/utils/actions/write-off-item.ts @@ -38,7 +38,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.WRITE_OFF_ITEM, { if (!existing) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `Reference ID "${refId}" not found.` + `Item ID "${refId}" not found.` ) }