feat(core-flows,js-sdk,medusa,types): draft order delete (#12172)

This commit is contained in:
Frane Polić
2025-05-28 14:37:00 +02:00
committed by GitHub
parent 52eebcee6f
commit 9866baa852
11 changed files with 521 additions and 2 deletions

View File

@@ -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(

View File

@@ -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)
})
},
})

View File

@@ -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<IOrderModuleService>(Modules.ORDER)
await service.deleteOrders(data.orderIds)
}
)

View File

@@ -1 +1,2 @@
export * from "./validate-draft-order"
export * from "./delete-draft-order"

View File

@@ -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<DeleteDraftOrderStepInput>) => {
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)
}
)

View File

@@ -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"

View File

@@ -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<HttpTypes.DeleteResponse<"draft-order">>(
`/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.

View File

@@ -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.
*/

View File

@@ -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,
})
}

View File

@@ -0,0 +1,21 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20250522181137 extends Migration {
override async up(): Promise<void> {
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<void> {
this.addSql(
`ALTER TABLE "order_summary" DROP CONSTRAINT IF EXISTS "order_summary_order_id_foreign";`
)
}
}

View File

@@ -728,7 +728,14 @@ export default class OrderModuleService
const creditLinesToCreate: CreateOrderCreditLineDTO[] = []
const createdOrders: InferEntityType<typeof Order>[] = []
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<void> {
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[]