Skip to main content
NEURAL X CIPHER official logo
Back to Engineering Insights
Backend Architecture8 min read|Updated 2026-11-11

Designing Multi-Vendor Order-Splitting Engines and PostGIS Spatial Discovery in Node.js

Backend engineering deep dive into Bipeers Marketplace and Karnival: atomic parent/child orders, commission ledgers, and radius queries.

Abrar Ali
Founder, CEO & Principal Software Architect
ORCID: 0009-0000-5688-0631
ARCHITECTURAL DIRECT ANSWER (TL;DR)

Multi-vendor marketplaces require a two-level order data model (ParentCheckoutOrder -> MerchantSubOrders) wrapped in an ACID database transaction so buyers pay once while each vendor fulfills independently. For hyper-local discovery, PostgreSQL with PostGIS GEOGRAPHY(Point, 4326) GiST indexes enables sub-50ms radius filtering and viewport map clustering.

KEY ENGINEERING TAKEAWAYS:
  • Never store a multi-vendor cart as a single flat order row; split into independent MerchantSubOrder state machines at checkout.
  • Use PostGIS ST_DWithin over GiST-indexed geography columns rather than Haversine math in application loops.
Designing Multi-Vendor Order-Splitting Engines and PostGIS Spatial Discovery in Node.js

1. The ParentOrder -> VendorSubOrder State Machine

When a customer on Bipeers Marketplace buys shoes from Merchant A, groceries from Merchant B and books a local freelancer in one checkout, a flat orders table breaks at once. What is the order's status if Merchant A has shipped and Merchant B is still packing? Who is refunded if only the groceries are cancelled? Which merchant sees the customer's whole basket?

A single-vendor store never has to answer these questions, which is why bolting marketplace behaviour onto a single-vendor schema fails. Status becomes a string like partially_shipped that no code path fully understands, and every merchant query needs a filter that somebody eventually forgets.

The model that works has two levels. One immutable parent order records what the customer agreed to: the full basket, the total and the payment. Under it sits one sub-order per merchant, each with its own items, its own totals and its own status. The customer pays once. Each merchant sees, fulfils and gets paid for only its own sub-order.

  • Parent order: customer, payment intent, grand total, delivery address. Written once, never edited.
  • Vendor sub-order: merchant, line items, subtotal, commission, payout amount, fulfilment status.
  • Ledger entries: one row per movement of money, so every payout can be traced to an order.

2. The Schema

The tables below are reduced to the columns that carry the design. Money is stored as integer minor units, never floating point. Prices are copied onto the order line at checkout, so a later price change in the catalogue cannot rewrite history. The commission rate is copied too, for the same reason: a merchant's new rate applies to new orders, not old ones.

Two-level order model with a commission ledger (simplified)sql
CREATE TABLE parent_orders (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id     UUID NOT NULL REFERENCES users(id),
  idempotency_key TEXT NOT NULL UNIQUE,
  grand_total     BIGINT NOT NULL,          -- minor units
  payment_status  TEXT NOT NULL DEFAULT 'pending',
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE vendor_sub_orders (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  parent_order_id UUID NOT NULL REFERENCES parent_orders(id),
  merchant_id     UUID NOT NULL REFERENCES merchants(id),
  subtotal        BIGINT NOT NULL,
  commission_rate NUMERIC(5,4) NOT NULL,    -- copied at checkout
  commission      BIGINT NOT NULL,
  payout          BIGINT NOT NULL,
  status          TEXT NOT NULL DEFAULT 'pending',
  UNIQUE (parent_order_id, merchant_id)
);

CREATE TABLE order_lines (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  sub_order_id UUID NOT NULL REFERENCES vendor_sub_orders(id),
  product_id   UUID NOT NULL,
  unit_price   BIGINT NOT NULL,             -- copied at checkout
  quantity     INT NOT NULL CHECK (quantity > 0)
);

CREATE TABLE ledger_entries (
  id           BIGSERIAL PRIMARY KEY,
  sub_order_id UUID NOT NULL REFERENCES vendor_sub_orders(id),
  account      TEXT NOT NULL,   -- 'merchant_payable' | 'platform_commission'
  amount       BIGINT NOT NULL, -- positive = credit, negative = reversal
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

The UNIQUE (parent_order_id, merchant_id) constraint guarantees one sub-order per merchant per checkout. The unique idempotency_key on the parent is what makes a retried checkout safe, as the next section shows.

3. Checkout as One Transaction

Splitting a basket creates several rows across four tables. If the process stops halfway, the customer may be charged for an order one merchant never receives. The whole split therefore runs inside a single ACID transaction: either every row exists or none does.

Three details matter. Prices are read from the database inside the transaction, never trusted from the app. Stock is decremented with a conditional update, so two customers cannot both buy the last item. And the idempotency key means a retry after a dropped connection returns the order that already exists instead of creating a second one.

Node.js checkout orchestrator: group by merchant, split, write oncetypescript
export async function checkout(input: {
  customerId: string;
  idempotencyKey: string;
  items: { productId: string; quantity: number }[];
}) {
  return db.transaction(async (tx) => {
    const existing = await tx.parentOrders.findByKey(input.idempotencyKey);
    if (existing) return existing; // Retry: return the first result.

    // Authoritative prices and merchants come from the database.
    const products = await tx.products.findMany(input.items.map((i) => i.productId));
    const byMerchant = groupBy(input.items, (i) => products.get(i.productId)!.merchantId);

    let grandTotal = 0;
    const parent = await tx.parentOrders.insert({
      customerId: input.customerId,
      idempotencyKey: input.idempotencyKey,
      grandTotal: 0,
    });

    for (const [merchantId, lines] of byMerchant) {
      for (const line of lines) {
        // Fails the whole transaction if stock ran out.
        await tx.inventory.decrementOrThrow(line.productId, line.quantity);
      }
      const subtotal = sum(lines, (l) => products.get(l.productId)!.price * l.quantity);
      const rate = await tx.merchants.commissionRate(merchantId);
      const commission = Math.round(subtotal * rate);

      const sub = await tx.subOrders.insert({
        parentOrderId: parent.id, merchantId, subtotal,
        commissionRate: rate, commission, payout: subtotal - commission,
      });
      await tx.orderLines.insertMany(sub.id, lines, products);
      grandTotal += subtotal;
    }

    await tx.parentOrders.setTotal(parent.id, grandTotal);
    return { ...parent, grandTotal };
  });
}

Payment is captured against the parent order's total. Ledger entries are written when the payment is confirmed, not at checkout, so an abandoned payment leaves no money trail to clean up. Push notifications to merchants are sent after the transaction commits, for the same reason given in our real-time systems article: never announce something that might still roll back.

4. Independent State Machines per Merchant

Each sub-order moves through its own lifecycle. Allowed transitions are written down as data, and every status change goes through one function that checks them. A merchant cannot mark an order delivered before it was dispatched, and nobody can revive a cancelled one.

The parent order has no status column of its own for fulfilment. Its status is derived from its children when it is read, which means it can never disagree with them.

Explicit transitions and a derived parent statustypescript
type SubOrderStatus =
  | "pending" | "accepted" | "preparing"
  | "dispatched" | "delivered" | "cancelled";

const ALLOWED: Record<SubOrderStatus, SubOrderStatus[]> = {
  pending: ["accepted", "cancelled"],
  accepted: ["preparing", "cancelled"],
  preparing: ["dispatched", "cancelled"],
  dispatched: ["delivered"],
  delivered: [],
  cancelled: [],
};

export function assertTransition(from: SubOrderStatus, to: SubOrderStatus) {
  if (!ALLOWED[from].includes(to)) {
    throw new InvalidTransitionError(from, to);
  }
}

export function deriveParentStatus(children: SubOrderStatus[]) {
  const active = children.filter((s) => s !== "cancelled");
  if (active.length === 0) return "cancelled";
  if (active.every((s) => s === "delivered")) return "completed";
  if (active.some((s) => s === "dispatched" || s === "delivered")) {
    return "partially_fulfilled";
  }
  return "in_progress";
}

Authorisation follows the same shape. Every merchant query is scoped by merchant_id at the data layer, ideally with row-level security in the database, so a merchant dashboard is unable to read another merchant's sub-order even if an API handler forgets a filter.

5. Commissions, Payouts and Partial Refunds

The ledger is append-only. When a payment is confirmed, each sub-order gets two entries: the merchant's payable amount and the platform's commission. Together they equal the sub-order subtotal, and across all sub-orders they equal what the customer paid. That equality is checked by a nightly job; a mismatch is a bug to find today, not at the end of the quarter.

A partial refund is where the two-level model pays for itself. If the grocery merchant cancels, only that sub-order changes state. The customer is refunded that subtotal, and the ledger receives reversing entries for that sub-order alone. The shoe merchant's order, payout and commission are untouched. Nothing is ever updated in place, so the full history of every payout can be reconstructed.

  • Payouts are computed from ledger entries for delivered sub-orders, never from live order rows.
  • Refunds add negative entries; they do not delete or edit earlier ones.
  • Commission rates are read from the sub-order, so a rate change never alters past orders.
  • A hold period between delivery and payout covers returns without clawing money back.

7. Clustering Map Pins on the Server

A map of a busy city can contain thousands of listings in view. Sending them all to the phone and drawing a marker for each makes the map unusable and the frame rate collapse.

The app sends the visible bounding box and zoom level, and the database returns clusters. At low zoom, points are snapped to a coarse grid and each cell comes back as one marker with a count. As the user zooms in, the grid gets finer until individual listings appear. The phone only ever draws a few dozen markers, which is how Karnival keeps its map at 60fps.

Grid clustering for the visible map areasql
-- :cell is the grid size in degrees, chosen from the zoom level.
SELECT ST_X(cell) AS lng,
       ST_Y(cell) AS lat,
       count(*)   AS listings,
       min(id::text) AS sample_id
  FROM (
    SELECT id,
           ST_SnapToGrid(location::geometry, :cell) AS cell
      FROM listings
     WHERE status = 'active'
       AND location::geometry && ST_MakeEnvelope(
             :west, :south, :east, :north, 4326)
  ) snapped
 GROUP BY cell;

When a cluster's count is one, the app uses sample_id to show the listing itself. An index on the geometry cast of the column keeps the bounding-box filter fast.

8. Putting It Together

Order splitting and spatial search look like separate topics, but they follow the same rule: put the invariant in the database. An order is split in one transaction, so it cannot be half-created. Statuses change through declared transitions, so they cannot skip steps. Money moves only through an append-only ledger, so it can always be explained. Distance is computed by an index built for it, so it stays fast as the catalogue grows.

Bipeers shipped as a full ecosystem in 14 weeks on this foundation, with customer, merchant and freelancer roles in one architecture. Karnival went from concept to release in eight.

If you are planning a marketplace, classifieds platform or any product with several sellers behind one checkout, see our marketplace and SaaS development service and the backend and API engineering behind it, or describe your project and we will outline the data model with you.

// READY TO IMPLEMENT THIS ARCHITECTURE?

Book a Free Strategy Call

Speak directly with Principal Software Architect Abrar Ali on architecture, timeline, and fixed-scope delivery.

Book a Free Strategy Call