Build your own marketplace from scratch using Medusa
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 Nasser in her tutorial series that inspired perseides.
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.
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 :
src/services/order.ts
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.
Don’t forget that in our marketplace logic, if a user doesn’t have a store_id, they considered admin.
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.
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?)
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 :
src/subscribers/orders/order-placed.ts
if (!order) { logger.failure(orderActivity, `OrderPlacedSubscriber | Order not found for order id ${data.id}.`) return}// #1 : Group Items By Store Idconst 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)}
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 :
src/subscribers/orders/order-placed.ts
// #2 : For each store, create a new order with the relevant items and shipping methodsfor (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.
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
src/subscribers/orders/order-placed.ts
// #2.3 : Create a new shipping method for each child order with a matching shipping option that is in the same storefor (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)}
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) => { // ℹ️ The argument `m` is the transaction manager 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.
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 :
src/subscribers/orders/order-updated.ts
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 already completed
This is an example, you can of course adjust to your needs.
src/subscribers/orders/order-updated.ts
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!
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[] }}
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.
Support my work
Your support helps me dedicate more time to creating high-quality content and building tools that benefit the community.
Implement Payments Splitting
This is the next chapter of this series, where we’ll learn how to implement payments splitting.