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 ea13f8ed7b..5bfc0552fa 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 @@ -185,6 +185,24 @@ medusaIntegrationTestRunner({ }) }) + describe("DELETE /draft-orders/:id", () => { + it("should delete a draft order", async () => { + const response = await api.delete( + `/admin/draft-orders/${testDraftOrder.id}`, + adminHeaders + ) + + expect(response.status).toBe(200) + expect(response.data).toEqual( + expect.objectContaining({ + id: testDraftOrder.id, + object: "draft-order", + deleted: true, + }) + ) + }) + }) + describe("POST /draft-orders/:id/convert-to-order", () => { it("should convert a draft order to an order", async () => { const response = await api.post( diff --git a/integration-tests/modules/__tests__/order/order.spec.ts b/integration-tests/modules/__tests__/order/order.spec.ts index 0a3b5ced3b..5f793a943e 100644 --- a/integration-tests/modules/__tests__/order/order.spec.ts +++ b/integration-tests/modules/__tests__/order/order.spec.ts @@ -1,5 +1,6 @@ import { medusaIntegrationTestRunner } from "@medusajs/test-utils" -import { IOrderModuleService } from "@medusajs/types" +import { IOrderModuleService, OrderDTO } from "@medusajs/types" +import { createOrderChangeWorkflow } from "@medusajs/core-flows" import { Modules } from "@medusajs/utils" import { adminHeaders, @@ -500,5 +501,252 @@ medusaIntegrationTestRunner({ }) }) }) + + it("should delete an order and related entities", async () => { + const toDeleteOrder = await orderModule.createOrders({ + region_id: "test_region_id", + email: "foo@bar.com", + metadata: { + foo: "bar", + }, + items: [ + { + title: "Custom Item 1", + quantity: 1, + unit_price: 20, + adjustments: [ + { + code: "VIP_25 ETH", + amount: "0.000000000000000005", + description: "VIP discount", + promotion_id: "prom_123", + provider_id: "coupon_kings", + }, + ], + }, + ], + sales_channel_id: "test", + shipping_address: { + first_name: "Shipping 1", + last_name: "Test 1", + address_1: "Test 1", + city: "Test 1", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Billing 1", + last_name: "Test 1", + address_1: "Test 1", + city: "Test 1", + country_code: "US", + postal_code: "12345", + }, + shipping_methods: [ + { + name: "Test shipping method", + amount: 10, + data: {}, + tax_lines: [ + { + description: "shipping Tax 1", + tax_rate_id: "tax_usa_shipping", + code: "code", + rate: 10, + }, + ], + adjustments: [ + { + code: "VIP_10", + amount: 1, + description: "VIP discount", + promotion_id: "prom_123", + }, + ], + }, + ], + currency_code: "usd", + customer_id: "joe", + }) + + const persistedOrder = (await orderModule.createOrders({ + region_id: "test_region_id", + email: "foo@bar.com", + metadata: { + foo: "bar", + }, + items: [ + { + title: "Custom Item 2", + quantity: 1, + unit_price: 50, + adjustments: [ + { + code: "VIP_25 ETH", + amount: "0.000000000000000005", + description: "VIP discount", + promotion_id: "prom_123", + provider_id: "coupon_kings", + }, + ], + }, + ], + sales_channel_id: "test", + shipping_address: { + first_name: "Shipping 2", + last_name: "Test 2", + address_1: "Test 2", + city: "Test 2", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Billing 2", + last_name: "Test 2", + address_1: "Test 2", + city: "Test 2", + country_code: "US", + postal_code: "12345", + }, + shipping_methods: [ + { + name: "Test shipping method", + amount: 10, + data: {}, + tax_lines: [ + { + description: "shipping Tax 2", + tax_rate_id: "tax_usa_shipping", + code: "code", + rate: 10, + }, + ], + adjustments: [ + { + code: "VIP_10", + amount: 1, + description: "VIP discount", + promotion_id: "prom_123", + }, + ], + }, + ], + currency_code: "usd", + customer_id: "joe", + })) as OrderDTO & { + shipping_address_id: string + billing_address_id: string + } + + const { result: toDeleteOrderEdit } = await createOrderChangeWorkflow( + appContainer + ).run({ + input: { + order_id: toDeleteOrder.id, + }, + }) + + const { result: persistedOrderEdit } = await createOrderChangeWorkflow( + appContainer + ).run({ + input: { + order_id: persistedOrder.id, + }, + }) + + await orderModule.deleteOrders([toDeleteOrder.id]) + + const orderItems = (await dbConnection.raw("select * from order_item;")) + .rows + + expect(orderItems.length).toBe(1) + expect(orderItems[0].id).toBe(persistedOrder.items[0].detail.id) + + /** + * ORDER ITEMS AND LINE ITEMS + */ + + const orderLineItems = ( + await dbConnection.raw("select * from order_line_item;") + ).rows + + expect(orderLineItems.length).toBe(1) + expect(orderLineItems[0].id).toBe(persistedOrder.items[0].id) + + const orderShipping = ( + await dbConnection.raw("select * from order_shipping;") + ).rows + + expect(orderShipping.length).toBe(1) + expect(orderShipping[0]).toEqual( + expect.objectContaining({ + order_id: persistedOrder.id, + shipping_method_id: persistedOrder.shipping_methods[0].id, + }) + ) + + /** + * ORDER SHIPPING AND SHIPPING METHODS + */ + + const orderShippingMethod = ( + await dbConnection.raw("select * from order_shipping_method;") + ).rows + + expect(orderShippingMethod.length).toBe(1) + expect(orderShippingMethod[0]).toEqual( + expect.objectContaining({ + id: persistedOrder.shipping_methods[0].id, + }) + ) + + /** + * ORDER BILLING AND SHIPPING ADDRESSES + */ + + const addresses = (await dbConnection.raw("select * from order_address;")) + .rows + + expect(addresses.length).toBe(2) + expect(addresses.map((a) => a.id)).toEqual( + expect.arrayContaining([ + persistedOrder.shipping_address_id, + persistedOrder.billing_address_id, + ]) + ) + + /** + * ORDER SUMMARY + */ + + const orderSummary = ( + await dbConnection.raw("select * from order_summary;") + ).rows + + expect(orderSummary.length).toBe(1) + expect(orderSummary[0].totals.original_order_total).toBe( + persistedOrder.summary.original_order_total + ) + + /** + * ORDER CHANGES + */ + + const orderChangeRows = ( + await dbConnection.raw("select * from order_change;") + ).rows + + expect(orderChangeRows.length).toBe(1) + expect(orderChangeRows[0].id).toBe(persistedOrderEdit.id) + + const orders = ( + await api.get("/admin/orders?fields=*shipping_address", adminHeaders) + ).data.orders + + expect(orders.length).toBe(1) + expect(orders[0].id).toBe(persistedOrder.id) + }) }, }) diff --git a/packages/core/core-flows/src/draft-order/steps/delete-draft-order.ts b/packages/core/core-flows/src/draft-order/steps/delete-draft-order.ts new file mode 100644 index 0000000000..4382b9e117 --- /dev/null +++ b/packages/core/core-flows/src/draft-order/steps/delete-draft-order.ts @@ -0,0 +1,26 @@ +import { IOrderModuleService } from "@medusajs/framework/types" +import { createStep } from "@medusajs/framework/workflows-sdk" +import { Modules } from "@medusajs/framework/utils" + +/** + * The details of canceling the orders. + */ +export type DeleteDraftOrdersStepInput = { + /** + * The IDs of the orders to delete. + */ + orderIds: string[] +} + +export const deleteDraftOrdersStepId = "delete-draft-orders" +/** + * This step deletes one or more draft orders. + */ +export const deleteDraftOrdersStep = createStep( + deleteDraftOrdersStepId, + async (data: DeleteDraftOrdersStepInput, { container }) => { + const service = container.resolve(Modules.ORDER) + + await service.deleteOrders(data.orderIds) + } +) diff --git a/packages/core/core-flows/src/draft-order/steps/index.ts b/packages/core/core-flows/src/draft-order/steps/index.ts index cffc22c6a9..cab0d45a09 100644 --- a/packages/core/core-flows/src/draft-order/steps/index.ts +++ b/packages/core/core-flows/src/draft-order/steps/index.ts @@ -1 +1,2 @@ export * from "./validate-draft-order" +export * from "./delete-draft-order" diff --git a/packages/core/core-flows/src/draft-order/workflows/delete-draft-order.ts b/packages/core/core-flows/src/draft-order/workflows/delete-draft-order.ts new file mode 100644 index 0000000000..5ef9ca7e51 --- /dev/null +++ b/packages/core/core-flows/src/draft-order/workflows/delete-draft-order.ts @@ -0,0 +1,85 @@ +import { + WorkflowData, + WorkflowResponse, + createStep, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" +import { OrderDTO } from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" + +import { removeRemoteLinkStep, useQueryGraphStep } from "../../common" +import { deleteDraftOrdersStep } from "../steps" + +/** + * The data to validate the order's cancelation. + */ +export type DeleteDraftOrderStepInput = { + /** + * The order ids to delete. + */ + order_ids: string[] +} + +const validateDraftOrdersStep = createStep( + "validate-draft-orders", + async (data: { orders: OrderDTO[] }) => { + if ( + data.orders.some( + (order) => order.status !== "draft" || !order.is_draft_order + ) + ) { + throw new Error("One or more orders are not draft") + } + + if (data.orders.some((order) => order.deleted_at)) { + throw new Error("One or more orders are already deleted") + } + } +) + +export const deleteDraftOrderWorkflowId = "delete-draft-order" +/** + * This workflow deletes draft orders. + * + * You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around canceling an order. + * + * @example + * const { result } = await deleteDraftOrderWorkflow(container) + * .run({ + * input: { + * order_ids: ["order_123", "order_456"], + * } + * }) + * + * @summary + * + * Delete draft orders. + * + * @property hooks.orderCanceled - This hook is executed after the order is canceled. You can consume this hook to perform custom actions on the canceled order. + */ +export const deleteDraftOrdersWorkflow = createWorkflow( + deleteDraftOrderWorkflowId, + (input: WorkflowData) => { + const orderQuery = useQueryGraphStep({ + entity: "orders", + fields: ["id", "status", "is_draft_order", "deleted_at"], + filters: { id: input.order_ids }, + options: { throwIfKeyNotFound: true }, + }).config({ name: "get-draft-order" }) + + const orders = transform({ orderQuery }, ({ orderQuery }) => { + return orderQuery.data + }) + + validateDraftOrdersStep({ orders }) + + removeRemoteLinkStep({ + [Modules.ORDER]: { order_id: input.order_ids }, + }) + + deleteDraftOrdersStep({ orderIds: input.order_ids }) + + return new WorkflowResponse(void 0) + } +) diff --git a/packages/core/core-flows/src/draft-order/workflows/index.ts b/packages/core/core-flows/src/draft-order/workflows/index.ts index 178eda788e..3d51d8a7dd 100644 --- a/packages/core/core-flows/src/draft-order/workflows/index.ts +++ b/packages/core/core-flows/src/draft-order/workflows/index.ts @@ -15,3 +15,4 @@ export * from "./update-draft-order-action-shipping-method" export * from "./update-draft-order-item" export * from "./update-draft-order-shipping-method" export * from "./remove-draft-order-shipping-method" +export * from "./delete-draft-order" diff --git a/packages/core/js-sdk/src/admin/draft-order.ts b/packages/core/js-sdk/src/admin/draft-order.ts index f02f127cc1..9b107898b4 100644 --- a/packages/core/js-sdk/src/admin/draft-order.ts +++ b/packages/core/js-sdk/src/admin/draft-order.ts @@ -162,6 +162,29 @@ export class DraftOrder { ) } + /** + * This method deletes a draft order. It sends a request to the + * [Delete Draft Order](https://docs.medusajs.com/api/admin#draft-orders_deleteordereditsid) API route. + * + * @param id - The draft order's ID. + * @param headers - Headers to pass in the request. + * + * @example + * sdk.admin.draftOrder.delete("order_123") + * .then(({ id, object, deleted }) => { + * console.log(id, object, deleted) + * }) + */ + async delete(id: string, headers?: ClientHeaders) { + return await this.client.fetch>( + `/admin/draft-orders/${id}`, + { + method: "DELETE", + headers, + } + ) + } + /** * This method updates a draft order. It sends a request to the * [Update Draft Order](https://docs.medusajs.com/api/admin#draft-orders_postdraftordersid) API route. diff --git a/packages/core/types/src/order/common.ts b/packages/core/types/src/order/common.ts index 51cd2322f9..576f6091d4 100644 --- a/packages/core/types/src/order/common.ts +++ b/packages/core/types/src/order/common.ts @@ -1147,6 +1147,11 @@ export interface OrderDTO { */ updated_at: string | Date + /** + * When the order was deleted. + */ + deleted_at?: string | Date + /** * The original item total of the order. */ diff --git a/packages/medusa/src/api/admin/draft-orders/[id]/route.ts b/packages/medusa/src/api/admin/draft-orders/[id]/route.ts index cf03681e1b..78773f2262 100644 --- a/packages/medusa/src/api/admin/draft-orders/[id]/route.ts +++ b/packages/medusa/src/api/admin/draft-orders/[id]/route.ts @@ -1,6 +1,7 @@ import { getOrderDetailWorkflow, updateDraftOrderWorkflow, + deleteDraftOrdersWorkflow, } from "@medusajs/core-flows" import { AuthenticatedMedusaRequest, @@ -54,3 +55,22 @@ export const POST = async ( .status(200) .json({ draft_order: result.data[0] as HttpTypes.AdminDraftOrder }) } + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + + await deleteDraftOrdersWorkflow(req.scope).run({ + input: { + order_ids: [id], + }, + }) + + res.status(200).json({ + id, + object: "draft-order", + deleted: true, + }) +} diff --git a/packages/modules/order/src/migrations/Migration20250522181137.ts b/packages/modules/order/src/migrations/Migration20250522181137.ts new file mode 100644 index 0000000000..5f65c3f07b --- /dev/null +++ b/packages/modules/order/src/migrations/Migration20250522181137.ts @@ -0,0 +1,21 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20250522181137 extends Migration { + override async up(): Promise { + this.addSql( + `DELETE FROM "order_summary" WHERE "order_id" NOT IN (SELECT id FROM "order");` + ) + + this.addSql(`ALTER TABLE "order_summary" + ADD CONSTRAINT + "order_summary_order_id_foreign" FOREIGN KEY ("order_id") REFERENCES "order" ("id") + ON UPDATE CASCADE + ON DELETE CASCADE;`) + } + + override async down(): Promise { + this.addSql( + `ALTER TABLE "order_summary" DROP CONSTRAINT IF EXISTS "order_summary_order_id_foreign";` + ) + } +} diff --git a/packages/modules/order/src/services/order-module-service.ts b/packages/modules/order/src/services/order-module-service.ts index 1dd70d3a43..62561a4c5a 100644 --- a/packages/modules/order/src/services/order-module-service.ts +++ b/packages/modules/order/src/services/order-module-service.ts @@ -728,7 +728,14 @@ export default class OrderModuleService const creditLinesToCreate: CreateOrderCreditLineDTO[] = [] const createdOrders: InferEntityType[] = [] - for (const { items, shipping_methods, credit_lines, ...order } of data) { + for (const { + items, + shipping_methods, + credit_lines, + shipping_address, + billing_address, + ...order + } of data) { const ord = order as any const shippingMethods = shipping_methods?.map((sm: any) => { @@ -844,6 +851,70 @@ export default class OrderModuleService }) } + @InjectTransactionManager() + // @ts-expect-error + async deleteOrders( + orderIds: string | string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const ids = Array.isArray(orderIds) ? orderIds : [orderIds] + + const orders = await this.orderService_.list( + { id: ids }, + { + select: ["id", "shipping_address_id", "billing_address_id"], + }, + sharedContext + ) + + const orderAddressIds = orders + .map((order) => [order.shipping_address_id, order.billing_address_id]) + .flat(1) + + const orderChanges = await this.orderChangeService_.list( + { order_id: ids }, + { select: ["id"] }, + sharedContext + ) + + const orderChangeIds = orderChanges.map((orderChange) => orderChange.id) + + const orderItems = await this.orderItemService_.list( + { order_id: ids }, + { select: ["id", "item_id"] }, + sharedContext + ) + + const lineItemIds = orderItems.map((orderItem) => orderItem.item_id) + + const orderShipping = await this.orderShippingService_.list( + { order_id: ids }, + { select: ["shipping_method_id"] }, + sharedContext + ) + + const orderShippingMethodIds = orderShipping.map( + (orderShipping) => orderShipping.shipping_method_id + ) + + await promiseAll([ + this.orderAddressService_.delete(orderAddressIds, sharedContext), + // Delete order changes & actions + this.orderChangeService_.delete(orderChangeIds, sharedContext), + ]) + + // Delete order, order items, summary, shipping methods and transactions + await super.deleteOrders(ids, sharedContext) + + await promiseAll([ + this.orderLineItemService_.delete(lineItemIds, sharedContext), + this.orderShippingMethodService_.delete( + orderShippingMethodIds, + sharedContext + ), + ]) + } + // @ts-expect-error async updateOrders( data: OrderTypes.UpdateOrderDTO[]