Building Khoomi - Week 3: Multi-Vendor Order Architecture
Last week I documented shop architecture. This week, I will be writing on how orders work when a customer buys from multiple shops in one checkout on Khoomi.
On Khoomi, a single cart can contain items from different sellers. At checkout, that becomes one payment but multiple fulfillment flows. Shop A might ship tomorrow while Shop B takes a week. The order system needs to handle this gracefully.
The Parent-Child Model
An order has embedded shop orders. One document, multiple fulfillment units:
type Order struct {
ID primitive.ObjectID `bson:"_id"`
OrderNumber string `bson:"order_number"`
CustomerID primitive.ObjectID `bson:"customer_id"`
CustomerEmail string `bson:"customer_email"`
ShopOrders []ShopOrder `bson:"shop_orders"` // Embedded
Pricing OrderPricing `bson:"pricing"`
ShippingAddress UserAddressExcerpt `bson:"shipping_address"`
Status OrderStatus `bson:"status"` // Overall
PaymentStatus string `bson:"payment_status"`
CreatedAt time.Time `bson:"created_at"`
PaidAt *time.Time `bson:"paid_at,omitempty"`
CancelledAt *time.Time `bson:"cancelled_at,omitempty"`
}
type ShopOrder struct {
OrderID primitive.ObjectID `bson:"order_id"`
ShopID primitive.ObjectID `bson:"shop_id"`
ShopName string `bson:"shop_name"`
Items []OrderItem `bson:"items"`
Subtotal int64 `bson:"subtotal"` // in kobo
ShippingCost int64 `bson:"shipping_cost"` // in kobo
ShopTotal int64 `bson:"shop_total"` // in kobo
ShopOrderStatus OrderStatus `bson:"shop_order_status"` // Independent
TrackingNumber string `bson:"tracking_number,omitempty"`
SellerPayout SellerPayout `bson:"seller_payout"`
Refund *ShopOrderRefund `bson:"refund,omitempty"` // Created on cancellation
}
The parent Order tracks payment. Each ShopOrder tracks its own fulfillment. For an example, Shop A marking their portion as shipped doesn’t affect Shop B's status(stays as “processing.”, “paid” or whatever status it is)
Why embed instead of separate collections? Atomic reads. Fetching an order for display is one query, not a join. The customer sees everything at once: their items, each shop’s status, the overall payment state.
The trade-off? Updating a single shop’s status requires updating the entire order document. MongoDB’s positional operator ($[elem]) handles this efficiently, but it’s still a larger write than updating a separate document.
Atomic Checkout
Creating an order touches multiple collections. Inventory must be reserved. The cart must be cleared. The order must be inserted. All or nothing:
func (s *orderService) CreateOrderFromCart(ctx context.Context, userID ObjectID, req CreateOrderRequest) (*Order, error) {
// Build order from cart items (grouping by shop)
order := buildOrderFromCart(...)
callback := func(sessCtx mongo.SessionContext) (any, error) {
// Reserve inventory for each item
for _, shop := range order.ShopOrders {
for _, item := range shop.Items {
filter := bson.M{
"_id": item.ListingID,
"inventory.quantity": bson.M{"$gte": item.Quantity},
}
update := bson.M{
"$inc": bson.M{"inventory.quantity": -item.Quantity},
}
result, err := listingColl.UpdateOne(sessCtx, filter, update)
if err != nil {
return nil, err
}
if result.ModifiedCount == 0 {
return nil, fmt.Errorf("insufficient inventory for '%s'", item.Title)
}
}
}
_, err := orderColl.InsertOne(sessCtx, order)
if err != nil {
return nil, err
}
_, err = s.cart.ClearCartItems(sessCtx, userID)
if err != nil {
return nil, err
}
return order, nil
}
result, err := database.ExecuteTransaction(ctx, s.db.MongoClient, callback)
if err != nil {
return nil, err
}
return result.(*Order), nil
}
The filter "inventory.quantity": bson.M{"$gte": item.Quantity} is critical. It ensures stock exists before decrementing. If two customers checkout simultaneously and only one item remains, exactly one succeeds. The other gets a clear error.
Why reserve at checkout instead of cart add? I covered this decision in Week 1. The short version: carts are abandoned constantly, and reserving at checkout avoids expiry logic and hold timers.
Independent Fulfillment
Each shop order has its own lifecycle:
PENDING → PAID → PROCESSING → SHIPPED → DELIVERED
↓
CANCELLED
When a shop updates their status, only the order belonging to their shop gets updated:
func (s *orderService) UpdateShopOrderStatus(ctx context.Context, params UpdateShopOrderParams) error {
filter := bson.M{
"_id": params.OrderID,
"shop_orders.shop_id": params.ShopID,
}
update := bson.M{
"$set": bson.M{
"shop_orders.$.shop_order_status": params.Status,
"updated_at": time.Now(),
},
}
if params.Status == OrderStatusShipped {
update["$set"].(bson.M)["shop_orders.$.shipped_at"] = time.Now()
}
callback := func(sessCtx mongo.SessionContext) (any, error) {
_, err := s.db.Coll.Orders.FindOneAndUpdate(sessCtx, filter, update).Decode(&order)
if err != nil {
return nil, err
}
// On delivery, release earnings to seller
if params.Status == OrderStatusDelivered {
err := s.wallet.ReleaseEarnings(sessCtx, params.ShopID, params.OrderID)
if err != nil {
return nil, err
}
}
return nil, nil
}
_, err := database.ExecuteTransaction(ctx, s.db.MongoClient, callback)
return err
}
The shop_orders.$ positional operator updates only the matching shop order within the parent document.
Why not separate order documents per shop? The customer paid once. They expect one order number, one receipt, one tracking page. If I split orders into separate documents per shop, I’d need to reassemble them for every customer-facing view. Extra queries, extra complexity.
Escrow via Wallet
Sellers don’t get paid immediately. The money sits in escrow until delivery:
// Package wallet manages seller earnings on Khoomi.
//
// The wallet has two balances:
// - Pending: from orders not yet delivered (cannot withdraw)
// - Available: from delivered orders (can withdraw)
//
// Money Flow:
// 1. CreditPending: Payment received -> +pending, +total_earnings
// 2. ReleaseEarnings: Order delivered -> -pending, +available
// 3. ProcessWithdrawal: Seller withdraws -> -available, +total_withdrawn
When payment succeeds, the seller’s pending balance increases:
func (s *walletService) CreditPendingBalance(ctx context.Context, shopID, orderID ObjectID, amount int64) error {
callback := func(sessCtx mongo.SessionContext) (any, error) {
// Check idempotency - already credited for this order?
count, _ := s.db.Coll.WalletTransactions.CountDocuments(sessCtx, bson.M{
"shop_id": shopID,
"order_id": orderID,
"type": TxTypeOrderEarning,
})
if count > 0 {
// Already credited
return nil, nil
}
tx := WalletTransaction{
ShopID: shopID,
Type: TxTypeOrderEarning,
Amount: amount,
OrderID: &orderID,
// ...
}
_, err := s.db.Coll.WalletTransactions.InsertOne(sessCtx, tx)
if err != nil {
return nil, err
}
update := bson.M{
"$inc": bson.M{
"pending_balance": amount,
"total_earnings": amount,
},
}
_, err = s.db.Coll.Wallets.UpdateOne(sessCtx, bson.M{"shop_id": shopID}, update)
return nil, err
}
_, err := database.ExecuteTransaction(ctx, s.db.MongoClient, callback)
return err
}
When the order is delivered, pending becomes available:
func (s *walletService) ReleaseEarnings(ctx context.Context, shopID, orderID ObjectID) error {
callback := func(sessCtx mongo.SessionContext) (any, error) {
// Find the pending transaction
var pendingTx WalletTransaction
err := s.db.Coll.WalletTransactions.FindOne(sessCtx, bson.M{
"shop_id": shopID,
"order_id": orderID,
"type": TxTypeOrderEarning,
}).Decode(&pendingTx)
if err != nil {
return nil, ErrPendingEarningsNotFound
}
amount := pendingTx.Amount
releaseTx := WalletTransaction{
ShopID: shopID,
Type: TxTypeEarningReleased,
Amount: amount,
OrderID: &orderID,
Description: "Earnings released - order delivered",
}
_, err = s.db.Coll.WalletTransactions.InsertOne(sessCtx, releaseTx)
if err != nil {
return nil, err
}
// Move from pending to available
walletUpdate := bson.M{
"$inc": bson.M{
"pending_balance": -amount,
"available_balance": amount,
},
}
_, err = s.db.Coll.Wallets.UpdateOne(sessCtx, bson.M{"shop_id": shopID}, walletUpdate)
return nil, err
}
_, err := database.ExecuteTransaction(ctx, s.db.MongoClient, callback)
return err
}
Why escrow? Buyer protection. If a seller never ships or order status is “paid”, the money can be refunded. If the item arrives damaged, there’s a dispute window before the seller can withdraw.
The trade-off? Sellers wait for their money. Cash flow suffers. But for a marketplace with unknown sellers, trust requires holding funds until delivery is confirmed.
Partial Cancellation
One shop can cancel their order without affecting others:
func (s *orderService) CancelOrderByShop(ctx context.Context, orderID, shopID ObjectID, reason string) error {
order, _ := s.GetOrderByID(ctx, orderID)
// Find this shop's order
var shopOrder *ShopOrder
for i := range order.ShopOrders {
if order.ShopOrders[i].ShopID == shopID {
shopOrder = &order.ShopOrders[i]
break
}
}
needsRefund := order.Status == OrderStatusPaid || order.Status == OrderStatusProcessing
callback := func(sessCtx mongo.SessionContext) (any, error) {
updateFields := bson.M{
"shop_orders.$[elem].shop_order_status": OrderStatusCancelled,
"shop_orders.$[elem].updated_at": time.Now(),
}
// Create refund record if order was paid
if needsRefund {
refund := ShopOrderRefund{
Status: RefundStatusPending,
Amount: shopOrder.ShopTotal,
InitiatedBy: RefundByShop,
Reason: reason,
RequestedAt: time.Now(),
RetryCount: 0,
}
updateFields["shop_orders.$[elem].refund"] = refund
}
update := bson.M{"$set": updateFields}
arrayFilters := options.Update().SetArrayFilters(options.ArrayFilters{
Filters: []any{bson.M{"elem.shop_id": shopID}},
})
_, err := s.db.Coll.Orders.UpdateOne(sessCtx, bson.M{"_id": orderID}, update, arrayFilters)
if err != nil {
return nil, err
}
// Restore inventory
for _, item := range shopOrder.Items {
inventoryUpdate := bson.M{
"$inc": bson.M{"inventory.quantity": item.Quantity},
}
_, _ = s.db.Coll.Listings.UpdateOne(sessCtx, bson.M{"_id": item.ListingID}, inventoryUpdate)
}
// Check if ALL shops are now cancelled
updatedOrder, _ := s.GetOrderByID(sessCtx, orderID)
allCancelled := true
for _, so := range updatedOrder.ShopOrders {
if so.ShopOrderStatus != OrderStatusCancelled {
allCancelled = false
break
}
}
// If all cancelled, cancel the whole order
if allCancelled {
mainUpdate := bson.M{
"$set": bson.M{
"status": OrderStatusCancelled,
"payment_status": "refund_pending",
"cancelled_at": time.Now(),
},
}
_, err = s.db.Coll.Orders.UpdateOne(sessCtx, bson.M{"_id": orderID}, mainUpdate)
}
return nil, err
}
_, err := database.ExecuteTransaction(ctx, s.db.MongoClient, callback)
return err
}
The $[elem] array filter lets me target a specific shop order by its shop_id. If Shop A cancels but Shop B is still fulfilling, the parent order stays active. Only Shop A’s portion shows as cancelled.
Why allow partial cancellation? Stock issues happen. A seller might realize they can’t fulfill an item after accepting the order. Cancelling the entire order (including items from other shops that are ready to ship) would punish buyers and other sellers for one shop’s mistake.
When a paid order is cancelled, it also creates a refund record:
type ShopOrderRefund struct {
Status RefundStatus `bson:"status"` // pending/processing/completed/failed
Amount int64 `bson:"amount"` // in kobo
InitiatedBy RefundInitiator `bson:"initiated_by"` // customer/shop/system
Reason string `bson:"reason,omitempty"`
RequestedAt time.Time `bson:"requested_at"`
ProcessedAt *time.Time `bson:"processed_at,omitempty"`
CompletedAt *time.Time `bson:"completed_at,omitempty"`
Reference string `bson:"reference,omitempty"`
FailureReason string `bson:"failure_reason,omitempty"`
RetryCount int `bson:"retry_count"`
LastRetryAt *time.Time `bson:"last_retry_at,omitempty"`
}
The refund starts as pending. A background job picks it up.
Automated Refund Processing
Refunds run on a scheduler, not inline with cancellation:
func (j *RefundsJob) Run(ctx context.Context) {
orders, _ := j.orderService.GetPendingRefunds(ctx)
for _, order := range orders {
for _, shopOrder := range order.ShopOrders {
if shopOrder.Refund == nil || shopOrder.Refund.Status != RefundStatusPending {
continue
}
// Max 5 retries before marking as failed
if shopOrder.Refund.RetryCount >= MaxRefundRetries {
j.orderService.MarkRefundAsFailed(ctx, order.ID, shopOrder.ShopID,
"Maximum retry attempts exceeded")
continue
}
j.orderService.ProcessShopOrderRefund(ctx, order.ID, shopOrder.ShopID)
}
}
}
Why async? Payment provider APIs fail. Network timeouts happen. If I processed refunds inline with cancellation, a Paystack outage would block the entire cancellation flow. With a scheduler, the cancellation succeeds immediately. The customer sees “cancelled” right away, and the actual money movement retries in the background until it works.
Processing a refund deducts from the seller’s pending balance:
func (s *orderService) ProcessShopOrderRefund(ctx context.Context, orderID, shopID ObjectID) error {
// Mark as processing (with retry count increment)
updateProcessing := bson.M{
"$set": bson.M{
"shop_orders.$.refund.status": RefundStatusProcessing,
"shop_orders.$.refund.processed_at": time.Now(),
},
"$inc": bson.M{
"shop_orders.$.refund.retry_count": 1,
},
}
s.db.Coll.Orders.UpdateOne(ctx, filter, updateProcessing)
// Deduct from seller wallet
err := s.wallet.DeductForRefund(ctx, shopID, orderID, shopOrder.Refund.Amount)
if err != nil {
// Revert to pending for retry
revertUpdate := bson.M{
"$set": bson.M{
"shop_orders.$.refund.status": RefundStatusPending,
"shop_orders.$.refund.failure_reason": fmt.Sprintf("Wallet deduction failed: %v", err),
},
}
s.db.Coll.Orders.UpdateOne(ctx, ...)
return err
}
completeUpdate := bson.M{
"$set": bson.M{
"shop_orders.$.refund.status": RefundStatusCompleted,
"shop_orders.$.refund.completed_at": time.Now(),
},
}
s.db.Coll.Orders.UpdateOne(ctx, ...)
return nil
}
The wallet deduction reverses the original credit:
func (s *walletService) DeductForRefund(ctx context.Context, shopID, orderID ObjectID, amount int64) error {
callback := func(sessCtx mongo.SessionContext) (any, error) {
// Idempotency check
count, _ := s.db.Coll.WalletTransactions.CountDocuments(sessCtx, bson.M{
"shop_id": shopID,
"order_id": orderID,
"type": TxTypeRefundDeduction,
})
if count > 0 {
return nil, ErrRefundAlreadyProcessed
}
// Verify sufficient pending balance
var w SellerWallet
s.db.Coll.Wallets.FindOne(sessCtx, bson.M{"shop_id": shopID}).Decode(&w)
if w.PendingBalance < amount {
return nil, ErrInsufficientPendingBalance
}
tx := WalletTransaction{
ShopID: shopID,
Type: TxTypeRefundDeduction,
Amount: amount,
OrderID: &orderID,
Description: "Refund deduction for cancelled order",
}
s.db.Coll.WalletTransactions.InsertOne(sessCtx, tx)
// Deduct from pending and total earnings
walletUpdate := bson.M{
"$inc": bson.M{
"pending_balance": -amount,
"total_earnings": -amount,
},
}
s.db.Coll.Wallets.UpdateOne(sessCtx, bson.M{"shop_id": shopID}, walletUpdate)
return nil, nil
}
_, err := database.ExecuteTransaction(ctx, s.db.MongoClient, callback)
return err
}
The refund deducts from both pending_balance and total_earnings. The seller never actually earned this money. It’s going back to the customer.

The trade-off? If the seller somehow withdrew before delivery (which shouldn’t happen, since pending balance can’t be withdrawn), the deduction fails. After 5 retries, the refund is marked as failed, and I get an alert to handle it manually.
Cleanup Expired Orders
Pending orders that never get paid should release their inventory:
func (s *orderService) CleanupExpiredPendingOrders(ctx context.Context, expirationHours int) (int64, error) {
expiredTime := time.Now().Add(-time.Duration(expirationHours) * time.Hour)
filter := bson.M{
"status": OrderStatusPending,
"created_at": bson.M{"$lt": expiredTime},
}
cursor, _ := s.db.Coll.Orders.Find(ctx, filter)
var expiredOrders []Order
cursor.All(ctx, &expiredOrders)
var cleanedCount int64
for _, order := range expiredOrders {
callback := func(sessCtx mongo.SessionContext) (any, error) {
// Cancel the order
update := bson.M{
"$set": bson.M{
"status": OrderStatusCancelled,
"payment_status": "expired",
"internal_note": fmt.Sprintf("Auto-cancelled: Payment not received within %d hours", expirationHours),
"shop_orders.$[].shop_order_status": OrderStatusCancelled,
},
}
result, err := s.db.Coll.Orders.UpdateOne(sessCtx,
bson.M{"_id": order.ID, "status": OrderStatusPending}, update)
if result.ModifiedCount == 0 {
// Already processed
return nil, nil
}
// Restore all inventory
for _, shop := range order.ShopOrders {
for _, item := range shop.Items {
inventoryUpdate := bson.M{
"$inc": bson.M{"inventory.quantity": item.Quantity},
}
_, _ = s.db.Coll.Listings.UpdateOne(sessCtx, bson.M{"_id": item.ListingID}, inventoryUpdate)
}
}
return result.ModifiedCount, nil
}
result, _ := database.ExecuteTransaction(ctx, s.db.MongoClient, callback)
if count, ok := result.(int64); ok {
cleanedCount += count
}
}
return cleanedCount, nil
}
This runs as a background job every 24 hours. That window gives customers enough time to complete bank transfers (which can take hours in Nigeria), but not long enough to hold inventory hostage indefinitely.
The double-check filter "status": OrderStatusPending in the update ensures idempotency. If payment came through between the find and update, the order won’t be cancelled. The filter simply won’t match.
Seller Payout Calculation
Each shop order tracks exactly what the seller receives:
type SellerPayout struct {
Amount int64 `bson:"amount"` // Customer paid (kobo)
PlatformFee int64 `bson:"platform_fee"` // Khoomi's cut (kobo)
TransactionFee int64 `bson:"transaction_fee"` // Payment processor (kobo)
NetAmount int64 `bson:"net_amount"` // Seller receives (kobo)
PayoutStatus string `bson:"payout_status"`
}
Calculated at checkout:
shopTotal := shopSubtotal + shippingCost + handlingFee - shopDiscount
platformFee := shopTotal * PLATFORM_FEE_RATE / PERCENT_DIVISOR / 100
transactionFee := shopTotal * TRANSACTION_FEE_RATE / PERCENT_DIVISOR / 100
netAmount := shopTotal - platformFee - transactionFee
shopOrder.SellerPayout = SellerPayout{
Amount: shopTotal,
PlatformFee: platformFee,
TransactionFee: transactionFee,
NetAmount: netAmount,
PayoutStatus: "pending",
}
Why calculate at checkout instead of delivery? Transparency. When a seller views an incoming order, they see exactly what they’ll receive. No surprises when withdrawal time comes. “You’ll get ₦54,000 from this ₦60,000 order” is clear.
The trade-off? Fee changes don’t apply retroactively. If I lower the platform fee tomorrow, orders placed today still use today’s rate. For accounting consistency, that’s actually what I want.
What I’d Do Differently
The parent order status. Currently Order.Status is supposed to be the “overall” status, but what does “processing” mean when Shop A is shipped and Shop B is still packing? I should probably derive it from shop order statuses instead of storing it separately. Or at least define clearer rules for when the parent status changes.
Refund status visibility. Refund status lives inside ShopOrder.Refund. That’s fine for displaying a single order, but when customer support asks “show me all failed refunds this week,” I have to scan every order document. A separate refunds collection with proper indexing would make those queries instant.
The Pattern
Same as previous weeks: optimize for the common case.
- Most orders have 1-2 shops → embed, don’t normalize
- Most checkouts succeed → reserve at checkout, not cart add
- Most shops fulfill successfully → escrow by default, release on delivery
- Most cancellations are partial → per-shop status, not all-or-nothing
- Most refunds succeed on first try → async processing with retry, not inline
The multi-vendor order system handles the 90% case elegantly. The 10% (complex disputes, multi-shop returns, refunds that fail after 5 retries) requires me to step in manually. At Khoomi’s current scale, that’s maybe one or two cases a week. Acceptable.
Next week: Wallets and seller withdrawals.
—Samuel