Medusa Marketplace #4 | Payments Splitting

Medusa Marketplace #4 | Payments Splitting

·

8 min read

Hello everyone!

In this new section, we'll take a look at payments in preparation for what's to come.

What's the goal here?

In the same spirit as Orders, we're going to divide a single Payment into several for each store, or to be more transparent, we're going to create dummy payments for each store.

Let me explain.

The idea behind perseides is to create a marketplace with as little friction as possible. In fact, by creating dummy payments for each child order, we'll be able to calculate the total amount available for each seller on a specific order and we will not have to update a lot of logic. An example is the refund of an order. With this system, the refund will recalculate the child payment (which will be shown to our seller), but also act on the parent payment, which will execute actions on the PaymentProcessor.

Extend the entity

It's always the same recipe when it comes to extending core features: first, you extend the entity :

// src/models/payment.ts
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'

import { Payment as MedusaPayment } from '@medusajs/medusa'

@Entity()
export class Payment extends MedusaPayment {
    @Index('PaymentParentId')
    @Column({ nullable: true })
    payment_parent_id?: string

    @ManyToOne(() => Payment, (payment) => payment.children)
    @JoinColumn({ name: 'payment_parent_id', referencedColumnName: 'id' })
    parent?: Payment

    @OneToMany(() => Payment, (payment) => payment.parent)
    @JoinColumn({ name: 'id', referencedColumnName: 'payment_parent_id' })
    children?: Payment[]
}

Create the migration

Once the entity has been extended, it's time to migrate and apply our changes :

npx typeorm migration:create src/migrations/add-payment-children
// src/migrations/<TIMESTAMP>-add-payment-children.ts

// ...
    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`ALTER TABLE "payment" ADD "payment_parent_id" character varying`)
        await queryRunner.query(`CREATE INDEX "PaymentParentId" ON "payment" ("payment_parent_id")`)
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`DROP INDEX "public"."PaymentParentId"`)
        await queryRunner.query(`ALTER TABLE "payment" DROP COLUMN "payment_parent_id"`)
    }
// ...

Then we build before executing the migrations :

yarn build
npx medusa migrations run

When you refresh your database, the new column payment_parent_id should appear:

Override the OrderService.createRefund function

Another important aspect concerning refunds with these child payments is that when a refund is made on a child order, the payment used to refund must be the one the parent order (the real payment) :

// src/services/order.ts

class OrderService extends MedusaOrderService {
// ... rest previously implemented
    async createRefund(orderId: string, refundAmount: number, reason: string, note?: string, config?: { no_notification?: boolean }): Promise<Order> {
        const order = await this.retrieveWithTotals(orderId, { relations: ['payments'] })

        if (!order.order_parent_id) {
            // This means we are refunding from the parent order...
            return await super.createRefund(orderId, refundAmount, reason, note, config)
        }

        // Means the refund have occured on a child order,
        // so we need to refund from the parent order and compute the new child order amount
        return await this.atomicPhase_(async (m) => {
            const orderRepo = m.withRepository(this.orderRepository_)
            const refundRepo = m.getRepository(Refund)

            // If the refund amount is greater than the order amount, we can't refund
            if (refundAmount > order.refundable_amount) {
                throw new MedusaError(MedusaError.Types.INVALID_ARGUMENT, 'Refund amount is greater than the order amount')
            }

            // We refund from the parent order
            let parentOrder = await this.retrieve(order.order_parent_id)
            parentOrder = await super.createRefund(order.order_parent_id, refundAmount, reason, `${note}\n\nRefund from child order : ${order.id}`, config)


            // We create a refund for the child order, for future computation (refundable_amount)
            const refund = refundRepo.create({
                order_id: order.id,
                amount: refundAmount,
                reason,
                note: `${note}\n\nRefund from child order : ${order.id}`,
                payment_id: order.payments?.at(0)?.id
            })
            await refundRepo.save(refund)

            // We check if the child order payment is fully refunded
            // If it is, we can set the payment status to REFUNDED
            // Otherwise, we set the payment status to PARTIALLY_REFUNDED
            const childOrderPayment = order.payments?.at(0)

            const amountRefunded = childOrderPayment.amount_refunded + refundAmount

            const isFullyRefunded = amountRefunded === childOrderPayment.amount

            await orderRepo.update(order.id, {
                payment_status: isFullyRefunded ? PaymentStatus.REFUNDED : PaymentStatus.PARTIALLY_REFUNDED
            })

            return await this.retrieve(order.id)
        })
    }

}

In the code snippet above, we make sure to check whether the order is a parent order or a child order, in the case of a parent order, no worries, the original behavior remains the original, on the other hand, in the case where a vendor requests a refund from a child order, the refund must take place on the parent order

We also create "fake" refunds on that "fake" payment, allowing us for a easier computation of the refundable amount later on.

Override the OrderService.capturePayment function

In this case, we'll make sure that when the payment for a parent order is captured, the payments for the child orders are also captured.

When we will configure the Stripe plugin, the parent payments will be captured automatically using the medusa-payment-stripe plugin options.

But we also needs to update child order payments :

// src/services/order.ts

class OrderService extends MedusaOrderService {
// ... rest previously implemented

   /**
     * When a parent payment is captured, we capture the parent order payment but also every child order payment
     */
    async capturePayment(orderId: string): Promise<Order> {
        const order = await this.retrieveWithTotals(orderId, { relations: ['payments', 'children'] })

        if (order.order_parent_id) {
            throw new MedusaError(
                MedusaError.Types.NOT_ALLOWED,
                `Order with id ${orderId} is a child order and cannot be captured.`
            )
        }

        return await this.atomicPhase_(async (m) => {
            const orderRepo = m.withRepository(this.orderRepository_)
            const paymentRepo = m.withRepository(PaymentRepository)

            for (const child of order.children) {
                const childOrder = await orderRepo.findOne({
                    where: {
                        id: child.id
                    },
                    relations: ['payments']
                })

                if (!childOrder.payments.at(0).captured_at) {
                    await paymentRepo.update(childOrder.payments.at(0).id, {
                        captured_at: new Date()
                    })
                    await orderRepo.update(child.id, {
                        payment_status: PaymentStatus.CAPTURED
                    })
                }
            }

            return await super.capturePayment(order.id)
        })
    }

}

Update the OrderPlaced subscriber

Once our Payment entity has been modified, we can update our previously created subscriber to create a Payment for each child Order created within the subscriber.

Update your code with the following :

import {
    LineItem,
    Logger,
    OrderService,
    PaymentStatus,
    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 { Payment } from '../../models/payment'

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 paymentRepo = m.getRepository(Payment)

        const orderActivity = logger.activity(`Splitting order ${data.id} into child orders...`)

        const order = await orderService.retrieveWithTotals(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
            let totalItemsAmount: number = 0
            for (const item of items) {
                const lineItem = lineItemRepo.create({
                    ...item,
                    order_id: savedChildOrder.id,
                    cart_id: null,
                    id: null,
                })

                await lineItemRepo.save(lineItem)


                // We compute the total order amount for the child order
                totalItemsAmount += item.total
            }

            // #2.3 : Create a new shipping method for each child order with a matching shipping option that is in the same store
            let totalShippingAmount: number = 0
            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)

                totalShippingAmount += shippingMethod.total
            }

            const childPayment = paymentRepo.create({
                ...order.payments[0], // In our case, we only have one payment for the order
                payment_parent_id: order.payments[0].id,
                order_id: savedChildOrder.id,
                amount: totalItemsAmount + totalShippingAmount, // This is the total amount of the child order
                cart_id: null,
                cart: null,
                id: null,
            })

            await paymentRepo.save(childPayment)

        }

        // #3 : Capture the payment for the parent order (it will also capture the child orders)
        await orderService.withTransaction(m).capturePayment(order.id)

        logger.success(orderActivity, `Order ${data.id} has been split into ${storesWithItems.size} child orders.`)

    })

}

export const config: SubscriberConfig = {
    event: OrderService.Events.PLACED,
    context: {
        subscriberId: 'order-placed-handler',
    },
}

From now on, for each order placed, a child payment will be created and linked to a child order.

In the screenshot below, here's an example of an order that has been split into several orders.

You can see that the total amount displayed is explicitly that of the products and shipping costs linked to that specific Store only :

   /**
     * When a parent payment is captured, we capture the parent order payment but also every child order payment
     */
    async capturePayment(orderId: string): Promise<Order> {
        const order = await this.retrieveWithTotals(orderId, { relations: ['payments', 'children'] })

        if (order.order_parent_id) {
            throw new MedusaError(
                MedusaError.Types.NOT_ALLOWED,
                `Order with id ${orderId} is a child order and cannot be captured.`
            )
        }

        return await this.atomicPhase_(async (m) => {
            const orderRepo = m.withRepository(this.orderRepository_)
            const paymentRepo = m.withRepository(PaymentRepository)

            for (const child of order.children) {
                const childOrder = await orderRepo.findOne({
                    where: {
                        id: child.id
                    },
                    relations: ['payments']
                })

                if (!childOrder.payments.at(0).captured_at) {
                    await paymentRepo.update(childOrder.payments.at(0).id, {
                        captured_at: new Date()
                    })
                    await orderRepo.update(child.id, {
                        payment_status: PaymentStatus.CAPTURED
                    })
                }
            }

            return await super.capturePayment(order.id)
        })
    }

What's Next ?

In the next section, we'll be integrating Stripe and Stripe Connect, so don't forget to create your Stripe and Stripe Connect account.

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!