Bug: deleteOrders deletes all order_address rows when address IDs are null (wipes completed orders via CASCADE)
Bug report
Describe the bug
Calling DELETE /admin/draft-orders/:id (via deleteDraftOrdersWorkflow → OrderModuleService.deleteOrders) can delete every row in order_address, not just addresses belonging to the draft being deleted.
When shipping_address_id / billing_address_id are null (common for POS/draft carts), deleteOrders builds an address ID list that includes null values and passes that array into orderAddressService_.delete(...).
Observed Postgres statement (with log_statement = mod):
delete from "order_address" returning "id"(no usable WHERE id IN (...) with real IDs)
Why this is catastrophic
Medusa's order schema defines:
FOREIGN KEY (shipping_address_id) REFERENCES order_address(id) ON DELETE CASCADE
FOREIGN KEY (billing_address_id) REFERENCES order_address(id) ON DELETE CASCADESo deleting all order_address rows cascade-deletes all orders that reference those addresses, including completed/paid orders.
In production we correlated:
DELETE /admin/draft-orders/<draft_id>from POS/Admin- Immediately followed by
delete from "order_address" returning "id" - Then all completed orders disappeared (0 soft-deletes; hard-deleted via CASCADE)
order_payment_collection/ payment rows remained as orphans
Affected version
@medusajs/medusa/@medusajs/order2.12.3- Likely present on other 2.x versions with the same
deleteOrdersimplementation
Steps to reproduce
- Create several completed orders that have shipping addresses.
- Create a draft order with
shipping_address_id/billing_address_id=null(typical POS hold cart). - Call
DELETE /admin/draft-orders/:idfor that draft. - Observe:
order_addresstable is emptied (or mass-deleted)- completed orders referencing those addresses are gone (CASCADE)
- only drafts without address FKs may remain
Expected behavior
Deleting one draft order should delete only that draft and its related rows (addresses for that order only, if any).
orderAddressService_.delete must never be called with null/empty IDs in a way that deletes the whole table.
Actual behavior
deleteOrders does:
const orderAddressIds = orders
.map((order) => [order.shipping_address_id, order.billing_address_id])
.flat(1)
await this.orderAddressService_.delete(orderAddressIds, sharedContext)When address IDs are null, this becomes effectively a wipe of order_address, which then CASCADE-deletes completed orders.
Same pattern is risky for empty/null entries in lineItemIds / orderShippingMethodIds / orderChangeIds.
Suggested fix
Filter falsy IDs and skip delete when empty:
const orderAddressIds = orders
.map((order) => [order.shipping_address_id, order.billing_address_id])
.flat(1)
.filter((id) => !!id)
if (orderAddressIds.length) {
await this.orderAddressService_.delete(orderAddressIds, sharedContext)
}Also consider changing address FKs from ON DELETE CASCADE to ON DELETE SET NULL (address is owned by the order; deleting an address should not delete the order).
Additional context
- Triggered from Medusa Admin / POS Electron clients hitting core
DELETE /admin/draft-orders/:id(not a custom route). - Workflow path:
deleteDraftOrdersWorkflow→deleteDraftOrdersStep→service.deleteOrders(orderIds). - This caused repeated production data loss of completed POS sales until mitigated at the DB/app layer.
Environment
- Medusa: 2.12.3
- Database: PostgreSQL 16
- Node: 22.x
Source: medusajs/medusa