Medusa Marketplace #3 | Orders Splitting

Medusa Marketplace #3 | Orders Splitting

·

13 min read

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 entities

  • a new order_parent_id column, which is nullable, allowing us to know whether an order has a parent or not

  • relations 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.

💡
Don't forget that in our marketplace, if a user doesn't have a 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 already completed

💡
This is an example, you can of course adjust to your needs.
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

Did you find this article valuable?

Support perseides by becoming a sponsor. Any amount is appreciated!