Hello everyone!
Many of you have been waiting for this part, so here we're going to cover how to manage orders for each seller. Indeed, the most logical and widely used way is the one outlined by Shahed Nasserin her tutorial series that inspired perseides.
What's the goal here ?
To ensure that our vendors can process their orders independently, we're going to extend the Order entity to add some new features, for example, we will split an Order into multiple child orders, allowing a vendor to only access its order and the items they need to fulfill.
Extend the Order
Extend the Order entity
First, we'll extend the entity we're getting used to :
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'
import { Order as MedusaOrder } from '@medusajs/medusa'
import { Store } from './store'
@Entity()
export class Order extends MedusaOrder {
@Index('OrderStoreId')
@Column({ nullable: true })
store_id?: string
@ManyToOne(() => Store, (store) => store.orders)
@JoinColumn({ name: 'store_id', referencedColumnName: 'id' })
store?: Store
@Index('OrderParentId')
@Column({ nullable: true })
order_parent_id?: string
@ManyToOne(() => Order, (order) => order.children)
@JoinColumn({ name: 'order_parent_id', referencedColumnName: 'id' })
parent?: Order
@OneToMany(() => Order, (order) => order.parent)
@JoinColumn({ name: 'id', referencedColumnName: 'order_parent_id' })
children?: Order[]
}
Here we have added :
a
store_id
as on our previous entitiesa new
order_parent_id
column, which is nullable, allowing us to know whether an order has a parent or notrelations that allow us to find out the parent or children of an order
Updating the Store entity
As usual, we've added a relationship that points to our Store, so we'll need to update it to add the orders property to the Store entity :
// src/models/store.ts
import { Entity, OneToMany } from 'typeorm'
import { Store as MedusaStore } from '@medusajs/medusa'
import { Product } from './product'
import { User } from './user'
import { ShippingOption } from './shipping-option'
import { ShippingProfile } from './shipping-profile'
import { Order } from './order'
@Entity()
export class Store extends MedusaStore {
@OneToMany(() => User, (user) => user.store)
members?: User[]
@OneToMany(() => Product, (product) => product.store)
products?: Product[]
@OneToMany(() => ShippingOption, (shippingOption) => shippingOption.store)
shippingOptions?: ShippingOption[]
@OneToMany(() => ShippingProfile, (shippingProfile) => shippingProfile.store)
shippingProfiles?: ShippingProfile[]
// ✅ Our missing relation
@OneToMany(() => Order, (order) => order.store)
orders?: Order[]
}
Create the migration
Once the entity and repository have been extended, it's time for the migration :
npx typeorm migration:create src/migrations/add-order-store-id-and-children
We can add thoses new features by updating the up and down functions :
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "order" ADD "store_id" character varying`)
await queryRunner.query(`CREATE INDEX "OrderStoreId" ON "order" ("store_id")`)
await queryRunner.query(`ALTER TABLE "order" ADD "order_parent_id" character varying`)
await queryRunner.query(`CREATE INDEX "OrderParentId" ON "order" ("order_parent_id")`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."OrderParentId"`)
await queryRunner.query(`ALTER TABLE "order" DROP COLUMN "order_parent_id"`)
await queryRunner.query(`DROP INDEX "public"."OrderStoreId"`)
await queryRunner.query(`ALTER TABLE "order" DROP COLUMN "store_id"`)
}
We can apply our changes by launching the server with yarn build
and npx medusa migrations run
.
Extend the OrderService
Ready to start integrating our features ? We'll make sure to only list the orders linked to a Store, and for the first time we'll also see how to prevent a Store from accessing an Order that do not belongs to it :
import { FindConfig, OrderService as MedusaOrderService, Selector } from '@medusajs/medusa'
import { Lifetime } from 'awilix'
import { MedusaError } from 'medusa-core-utils'
import type { User } from '../models/user'
import type { Order } from '../models/order'
type OrderSelector = {
store_id?: string
} & Selector<Order>
class OrderService extends MedusaOrderService {
static LIFE_TIME = Lifetime.TRANSIENT
protected readonly loggedInUser_: User | null
constructor(container, options) {
// @ts-ignore
super(...arguments)
try {
this.loggedInUser_ = container.loggedInUser
} catch (e) {
// avoid errors when backend first runs
}
}
async list(selector: OrderSelector, config?: FindConfig<Order>): Promise<Order[]> {
if (!selector.store_id && this.loggedInUser_?.store_id) {
selector.store_id = this.loggedInUser_.store_id
}
config.select?.push('store_id')
config.relations?.push('store')
return await super.list(selector, config)
}
async listAndCount(selector: OrderSelector, config?: FindConfig<Order>): Promise<[Order[], number]> {
if (!selector.store_id && this.loggedInUser_?.store_id) {
selector.store_id = this.loggedInUser_.store_id
}
config.select?.push('store_id')
config.relations?.push('store')
return await super.listAndCount(selector, config)
}
async retrieve(orderId: string, config: FindConfig<Order> = {}): Promise<Order> {
config.relations = [...(config.relations || []), 'store']
const order = await super.retrieve(orderId, config)
if (order.store?.id &&
this.loggedInUser_?.store_id &&
order.store.id !== this.loggedInUser_.store_id) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Order with id ${orderId} was not found`
)
}
return order
}
}
export default OrderService
Three functions have been overrided in the above code. For the first two, if you've followed the previous sections, they simply add a WHERE
condition to our SQL query to target a specific store_id
if we have a logged in user with a store id.
For the retrieve
function, which allows us to retrieve an order, here we retrieve the Order with its associated store (or not, in the case of a parent Order) with the original function.
And we take care to validate after we have the Order, to compare the store_id of the order with that of the logged-in user.
store_id
, they considered admin.Create Order's subscribers
Ok so get ready, this part will be a little longer, but I assure you it's essential to your marketplace.
As I said at the beginning of this part, the idea is to split an order into several child orders, based on each store in an order.
For example, if the customer has created an order with 3 different products, all from a different store (3 different stores), we'd expect 3 different orders.
Order Placed Event
Create the Order placed subscriber
Here, I'll create a folder inside the src/subscribers
folder and name it orders
, so that all subscribers linked to orders will be inside.
We can now create the order-placed.ts
subscriber inside that new folder :
// src/subscribers/orders/order-placed.ts
import {
Logger,
OrderService,
type SubscriberArgs,
type SubscriberConfig
} from '@medusajs/medusa'
import { EntityManager } from 'typeorm'
export default async function handleOrderPlaced({
data,
eventName,
container,
pluginOptions,
}: SubscriberArgs<Record<string, string>>) {
const manager = container.resolve<EntityManager>('manager')
const logger = container.resolve<Logger>('logger')
}
export const config: SubscriberConfig = {
event: OrderService.Events.PLACED,
context: {
subscriberId: 'order-placed-handler',
},
}
For the moment, we're going to keep things very simple and simply have this piece of code that we'll feed in as we go along.
By the way, it reminds me that this is the first time we're going to use the manager
.
The manager will enable us to create a transaction, which will be very useful here and we have already used in the previous part quickly.
A transaction is a way to ensure that multiple database operations are executed as a single atomic unit of work. This means that either all operations succeed and are committed to the database, or if any operation fails, all operations are rolled back and the database remains unchanged (Pretty cool, right?)
Retrieve the Order placed
First, we'll retrieve the order that has just been created and intercepted by our subscriber :
// ... rest for size purposes
await manager.transaction(async (m) => {
const orderService: OrderService = container.resolve<OrderService>('orderService')
const productService: ProductService = container.resolve<ProductService>('productService')
const shippingOptionService: ShippingOptionService =
container.resolve<ShippingOptionService>('shippingOptionService')
const orderRepo = m.getRepository(Order)
const lineItemRepo = m.getRepository(LineItem)
const shippingMethodRepo = m.getRepository(ShippingMethod)
const orderActivity = logger.activity(`Splitting order ${data.id} into child orders...`)
const order = await orderService.retrieve(data.id, {
relations: ['items', 'items.variant', 'items.variant.prices', 'cart', 'payments', 'shipping_methods'],
})
if (!order) {
logger.failure(orderActivity, `OrderPlacedSubscriber | Order not found for order id ${data.id}.`)
return
}
// We have successfully retrieved the Order,
// we'll add our next logic here.
})
// ... rest for size purposes
Grouping LineItems by Store
It's easier to group products by store so you know how many distinct stores there are, and it will make creating a child order easier :
if (!order) {
logger.failure(orderActivity, `OrderPlacedSubscriber | Order not found for order id ${data.id}.`)
return
}
// #1 : Group Items By Store Id
const storesWithItems = new Map<string, Order['items']>()
for (const item of order.items) {
const product: Product = await productService.retrieve(item.variant.product_id)
const storeId = product.store_id
if (!storeId) {
logger.failure(orderActivity, `OrderPlacedSubscriber | product.store_id not found for product ${product.id}.`)
continue
}
if (!storesWithItems.has(storeId)) {
storesWithItems.set(storeId, [])
}
storesWithItems.get(storeId).push(item)
}
Creating a child Order for each store
Just below the code above, we will begin a loop on each item in our Map to construct a child order for each of the shops, including all of the LineItems indicated above :
// #2 : For each store, create a new order with the relevant items and shipping methods
for (const [storeId, items] of storesWithItems.entries()) {
// #2.1 : Create a new order
const childOrder = orderRepo.create({
...order,
order_parent_id: order.id,
store_id: storeId,
cart_id: null,
cart: null,
id: null,
shipping_methods: [],
})
const savedChildOrder = await orderRepo.save(childOrder)
// #2.2 : Create a new line item for each item in the order
for (const item of items) {
const lineItem = lineItemRepo.create({
...item,
order_id: savedChildOrder.id,
cart_id: null,
id: null,
})
await lineItemRepo.save(lineItem)
}
// Do not close the for loop yet.
Create a ShippingMethod for each child order
Still inside the loop, we'll create a ShippingMethod for each store, so that each store can manage the progress of the delivery independently. If you don't know the difference between a ShippingMethod and a ShippingOption, I invite you to read this part of the documentation
// #2.3 : Create a new shipping method for each child order with a matching shipping option that is in the same store
for (const shippingMethod of order.shipping_methods) {
const shippingOption = await shippingOptionService.retrieve(shippingMethod.shipping_option_id)
if (shippingOption.store_id !== storeId) {
continue
}
const newShippingMethod = shippingMethodRepo.create({
...shippingMethod,
id: null,
cart_id: null,
cart: null,
order_id: savedChildOrder.id,
})
await shippingMethodRepo.save(newShippingMethod)
}
Complete implementation
// src/subscribers/orders/order-placed.ts
import {
LineItem,
Logger,
OrderService,
ShippingMethod,
type SubscriberArgs,
type SubscriberConfig
} from '@medusajs/medusa'
import ShippingOptionService from 'src/services/shipping-option'
import { EntityManager } from 'typeorm'
import { Order } from '../../models/order'
import { Product } from '../../models/product'
import ProductService from '../../services/product'
export default async function handleOrderPlaced({
data,
eventName,
container,
pluginOptions,
}: SubscriberArgs<Record<string, string>>) {
const manager = container.resolve<EntityManager>('manager')
const logger = container.resolve<Logger>('logger')
await manager.transaction(async (m) => {
const orderService: OrderService = container.resolve<OrderService>('orderService')
const productService: ProductService = container.resolve<ProductService>('productService')
const shippingOptionService: ShippingOptionService =
container.resolve<ShippingOptionService>('shippingOptionService')
const orderRepo = m.getRepository(Order)
const lineItemRepo = m.getRepository(LineItem)
const shippingMethodRepo = m.getRepository(ShippingMethod)
const orderActivity = logger.activity(`Splitting order ${data.id} into child orders...`)
const order = await orderService.retrieve(data.id, {
relations: ['items', 'items.variant', 'items.variant.prices', 'cart', 'payments', 'shipping_methods'],
})
if (!order) {
logger.failure(orderActivity, `OrderPlacedSubscriber | Order not found for order id ${data.id}.`)
return
}
// #1 : Group Items By Store Id
const storesWithItems = new Map<string, Order['items']>()
for (const item of order.items) {
const product: Product = await productService.retrieve(item.variant.product_id)
const storeId = product.store_id
if (!storeId) {
logger.failure(orderActivity, `OrderPlacedSubscriber | product.store_id not found for product ${product.id}.`)
continue
}
if (!storesWithItems.has(storeId)) {
storesWithItems.set(storeId, [])
}
storesWithItems.get(storeId).push(item)
}
// #2 : For each store, create a new order with the relevant items and shipping methods
for (const [storeId, items] of storesWithItems.entries()) {
// #2.1 : Create a new order
const childOrder = orderRepo.create({
...order,
order_parent_id: order.id,
store_id: storeId,
cart_id: null,
cart: null,
id: null,
shipping_methods: [],
})
const savedChildOrder = await orderRepo.save(childOrder)
// #2.2 : Create a new line item for each item in the order
for (const item of items) {
const lineItem = lineItemRepo.create({
...item,
order_id: savedChildOrder.id,
cart_id: null,
id: null,
})
await lineItemRepo.save(lineItem)
}
// #2.3 : Create a new shipping method for each child order with a matching shipping option that is in the same store
for (const shippingMethod of order.shipping_methods) {
const shippingOption = await shippingOptionService.retrieve(shippingMethod.shipping_option_id)
if (shippingOption.store_id !== storeId) {
continue
}
const newShippingMethod = shippingMethodRepo.create({
...shippingMethod,
id: null,
cart_id: null,
cart: null,
order_id: savedChildOrder.id,
})
await shippingMethodRepo.save(newShippingMethod)
}
}
logger.success(orderActivity, `OrderPlacedSubscriber | Order ${data.id} has been split into ${storesWithItems.size} child orders.`)
})
}
export const config: SubscriberConfig = {
event: OrderService.Events.PLACED,
context: {
subscriberId: 'order-placed-handler',
},
}
Perfect! We now have a way of splitting an Order into several child Orders!
However, we still need to listen to other Order events.
Order Updated Event
Here, we're going to intercept an order whenever it has been updated. The idea is to be able to update the parent Order according to the status of its children :
import {
FulfillmentStatus,
OrderStatus,
PaymentStatus,
type SubscriberArgs,
type SubscriberConfig,
} from '@medusajs/medusa'
import type OrderRepository from '@medusajs/medusa/dist/repositories/order'
import type { Order } from '../../models/order'
import OrderService from '../../services/order'
export default async function handleOrderUpdated({
data,
eventName,
container,
pluginOptions,
}: SubscriberArgs<Record<string, string>>) {
const orderService: OrderService = container.resolve('orderService')
const orderRepo: typeof OrderRepository = container.resolve('orderRepository')
const order = await orderService.retrieve(data.id)
if (!order.order_parent_id) {
return
}
const parentOrder = await orderService.retrieve(order.order_parent_id, {
relations: ['children'],
})
const status = await getStatusFromChildren(parentOrder)
if (status !== parentOrder.status) {
switch (status) {
case OrderStatus.CANCELED:
await orderService.cancel(parentOrder.id)
case OrderStatus.ARCHIVED:
await orderService.archive(parentOrder.id)
case OrderStatus.COMPLETED:
await orderService.completeOrder(parentOrder.id)
default:
parentOrder.status = status
parentOrder.fulfillment_status = (status === 'completed'
? FulfillmentStatus.SHIPPED
: status) as unknown as FulfillmentStatus
parentOrder.payment_status = (status === 'completed') ? PaymentStatus.CAPTURED : status as unknown as PaymentStatus
await orderRepo.save(parentOrder)
}
}
}
async function getStatusFromChildren(order: Order) {
if (!order.children) {
return order.status
}
let statuses = order.children.map((child) => child.status)
//remove duplicate statuses
statuses = [...new Set(statuses)]
if (statuses.length === 1) {
return statuses[0]
}
statuses = statuses.filter((status) => status !== OrderStatus.CANCELED && status !== OrderStatus.ARCHIVED)
if (!statuses.length) {
//all child orders are archived or canceled
return OrderStatus.CANCELED
}
if (statuses.length === 1) {
return statuses[0]
}
//check if any order requires action
const hasRequiresAction = statuses.some((status) => status === OrderStatus.REQUIRES_ACTION)
if (hasRequiresAction) {
return OrderStatus.REQUIRES_ACTION
}
//since more than one status is left and we filtered out canceled, archived,
//and requires action statuses, only pending and complete left. So, return pending
return OrderStatus.PENDING
}
export const config: SubscriberConfig = {
event: [
OrderService.Events.UPDATED,
OrderService.Events.FULFILLMENT_CREATED,
OrderService.Events.FULFILLMENT_CANCELED,
OrderService.Events.GIFT_CARD_CREATED,
OrderService.Events.ITEMS_RETURNED,
OrderService.Events.PAYMENT_CAPTURED,
OrderService.Events.PAYMENT_CAPTURE_FAILED,
OrderService.Events.REFUND_CREATED,
OrderService.Events.REFUND_FAILED,
OrderService.Events.RETURN_ACTION_REQUIRED,
OrderService.Events.RETURN_REQUESTED,
OrderService.Events.SHIPMENT_CREATED,
OrderService.Events.SWAP_CREATED,
],
context: {
subscriberId: 'order-updated-handler',
},
}
Create a subscriber that will update the child order's status
Medusa doesn't implement any predefined conditions for setting the status of an order to completed
, in our example below.
In our example below, we'll set the status of an order to “complete” when :
This order has a payment_status of
completed
.This order has a fulfillment_status of
completed
.We'll also make sure that the order in question hasn't been
canceled
,archived
or alreadycompleted
import {
type SubscriberConfig,
type SubscriberArgs,
FulfillmentStatus,
PaymentStatus,
OrderStatus,
} from '@medusajs/medusa'
import OrderService from '../../services/order'
import type { Order } from '../../models/order'
export default async function handleOrderUpdated({
data,
eventName,
container,
pluginOptions,
}: SubscriberArgs<Record<string, string>>) {
const orderService: OrderService = container.resolve('orderService')
const order = await orderService.retrieve(data.id)
if (!order.order_parent_id) {
return
}
const parentOrder = await orderService.retrieve(order.order_parent_id, {
relations: ['children'],
})
await updateStatusOfChildren(parentOrder, orderService)
}
/**
* This function is executed when a child order is updated.
* It checks if the child order has a payment status of "captured" and a fulfillment status of "shipped".
* If both conditions are met, it updates the child order's status to "complete", allowing a parent order to be marked as "complete" too.
*/
async function updateStatusOfChildren(order: Order, orderService: OrderService) {
if (!order.children) {
return
}
const ordersToComplete = order.children
.filter((child) => child.payment_status === PaymentStatus.CAPTURED)
.filter((child) => child.fulfillment_status === FulfillmentStatus.SHIPPED)
.filter(
(child) =>
child.status !== OrderStatus.CANCELED &&
child.status !== OrderStatus.ARCHIVED &&
child.status !== OrderStatus.COMPLETED,
)
if (ordersToComplete.length === 0) {
return
}
for (const order of ordersToComplete) {
await orderService.completeOrder(order.id)
}
}
export const config: SubscriberConfig = {
event: [
OrderService.Events.UPDATED,
OrderService.Events.FULFILLMENT_CREATED,
OrderService.Events.FULFILLMENT_CANCELED,
OrderService.Events.GIFT_CARD_CREATED,
OrderService.Events.ITEMS_RETURNED,
OrderService.Events.PAYMENT_CAPTURED,
OrderService.Events.PAYMENT_CAPTURE_FAILED,
OrderService.Events.REFUND_CREATED,
OrderService.Events.REFUND_FAILED,
OrderService.Events.RETURN_ACTION_REQUIRED,
OrderService.Events.RETURN_REQUESTED,
OrderService.Events.SHIPMENT_CREATED,
OrderService.Events.SWAP_CREATED,
],
context: {
subscriberId: 'child-order-updated-handler',
},
}
And now you've successfully implemented the splitting of one order into several for each Store! Granted, this part of the program was rather heavy and involved a lot of different concepts, so don't hesitate to take the time to reread the code and try to understand the integrated flow.
If you have any questions, you can send me a DM on Twitter/X or on the official Medusa Discord!
What's Next ?
The next part will be a little special, we'll be looking at Payments entity, and we'll be extending it, but this will be more of an exercise for you, of course the solution will also be included, but it might be nice to practice customizing an entity on your own.
Common Issues
I have types errors!
Do not forget to update you index.d.ts
file created earlier, at this point, and what we have used, you should have an index.d.ts
file that looks like this :
// src/index.d.ts
import type { Product } from './models/product'
import type { ShippingProfile } from './models/shipping-profile'
import type { ShippingOption } from './models/shipping-option'
import type { Order } from './models/shipping-order'
declare module '@medusajs/medusa/dist/models/product' {
interface Product {
store_id?: string
store?: Store
}
}
declare module '@medusajs/medusa/dist/models/shipping-profile' {
interface ShippingProfile {
store_id?: string
store?: Store
}
}
declare module '@medusajs/medusa/dist/models/shipping-option' {
interface ShippingOption {
store_id?: string
store?: Store
}
}
declare module '@medusajs/medusa/dist/models/order' {
interface Order {
store_id?: string
store?: Store
order_parent_id?: string
parent?: Order
children?: Order[]
}
}
GitHub Branch
You can access the complete part's code here.
Contact
You can contact me on Discord and X with the same username : @adevinwild