DEV Community

Cover image for Multi-Vendor Checkout: Splitting One Cart by Seller
Muhammad Ali Mughal
Muhammad Ali Mughal

Posted on

Multi-Vendor Checkout: Splitting One Cart by Seller

A multi-vendor cart looks like one purchase to the customer and is really three or four separate contracts underneath. Each seller ships from a different place, on a different carrier, with its own stock, its own return window and its own payout. Most marketplace bugs I have been asked to fix come from storing that as one order with a seller_id on each line, then trying to bolt on partial shipments, partial refunds and per-seller status later.

I run Loop Origin, where my team builds custom e-commerce and marketplace platforms, mostly on Laravel, Vue 3 and PostgreSQL. I wrote this with help from AI tools and checked every query and claim myself. The schema and code below are simplified from patterns I use in production.

How should a marketplace split one cart across multiple sellers?

Direct answer: Create one parent order for the customer's payment and one child order per seller for fulfillment. Quote shipping per seller group, allocate order-level discounts to child orders with a deterministic rounding rule, capture payment once, and let each child order move through its own state machine. Derive the customer-facing status from the children instead of storing it.

Three ways to model it

Model Payment Fulfillment and status Refunds Where it breaks
One order, seller_id per line One charge One status for all sellers Line-level math every time "Shipped" is false when one of three sellers shipped
Parent order + seller sub-orders One charge on the parent Independent per seller Scoped to a sub-order Needs allocation logic up front
Separate checkout per seller One charge per seller Independent Simple Customer pays three times, more card declines and fees

The second model costs more at the start and saves the most later. The rest of this post assumes it.

The schema

CREATE TABLE orders (               -- what the customer paid for
  id               bigserial PRIMARY KEY,
  customer_id      bigint NOT NULL,
  currency         char(3) NOT NULL,
  items_total      bigint NOT NULL,  -- minor units
  shipping_total   bigint NOT NULL,
  discount_total   bigint NOT NULL,
  grand_total      bigint NOT NULL,
  payment_intent   text UNIQUE,
  idempotency_key  uuid NOT NULL UNIQUE,
  created_at       timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE seller_orders (        -- what one seller must fulfil
  id               bigserial PRIMARY KEY,
  order_id         bigint NOT NULL REFERENCES orders(id),
  seller_id        bigint NOT NULL,
  ship_from_id     bigint NOT NULL,  -- seller warehouse
  shipping_method  text   NOT NULL,
  items_total      bigint NOT NULL,
  shipping_total   bigint NOT NULL,
  discount_alloc   bigint NOT NULL,  -- share of order-level coupon
  status           text   NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending','accepted','packed','shipped','delivered','cancelled','refunded')),
  accept_by        timestamptz NOT NULL,  -- seller SLA
  UNIQUE (order_id, seller_id, ship_from_id)
);

CREATE TABLE order_lines (
  id               bigserial PRIMARY KEY,
  seller_order_id  bigint NOT NULL REFERENCES seller_orders(id),
  sku_id           bigint NOT NULL,
  qty              int    NOT NULL CHECK (qty > 0),
  unit_price       bigint NOT NULL,
  qty_cancelled    int    NOT NULL DEFAULT 0,
  qty_refunded     int    NOT NULL DEFAULT 0
);
Enter fullscreen mode Exit fullscreen mode

Two details carry a lot. The unique key includes ship_from_id, because one seller with two warehouses is two shipments, not one. And money is stored in minor units as integers, because the allocation step below has to add up to the exact cent.

Grouping the cart and quoting shipping

Group lines by seller and origin, then quote each group in parallel with a hard timeout. One slow carrier API should not block the whole checkout.

use Illuminate\Http\Client\Pool;
use Illuminate\Http\Client\Response;

$groups = $cart->lines->groupBy(fn ($l) => $l->seller_id.'-'.$l->shipFromId());

$responses = Http::pool(fn (Pool $pool) => $groups->map(fn ($lines, $key) =>
    $pool->as((string) $key)->timeout(3)->post(
        $this->carrierUrlFor($lines->first()->seller_id),
        $this->quotePayload($lines, $address)
    )
)->values()->all());

$quotes = $groups->map(function ($lines, $key) use ($responses) {
    $r = $responses[$key] ?? null;
    if ($r instanceof Response && $r->successful()) {
        return ShippingQuote::fromCarrier($r->json());
    }
    Log::warning('shipping.flat_rate_fallback', ['group' => $key]);
    return ShippingQuote::flatRate($lines->first()->seller);
});
Enter fullscreen mode Exit fullscreen mode

Http::pool sends the requests concurrently, and a connection failure comes back as an exception object instead of a Response, which is why the check matters. The fallback is a business decision: a flat rate the seller agreed to in advance is better than a failed checkout, and the log line shows which sellers depend on it.

Show the customer one line per seller group ("Ships from Seller A, 3–5 days, Rs 250"). Splitting shipping per seller is honest and cuts disputes about why shipping costs more than expected.

Allocating discounts without losing a cent

An order-level coupon of Rs 500 across three sellers has to be split, because each seller's payout and each partial refund depends on it. Allocate in proportion to each seller's items total, then hand leftover units out by largest remainder:

function allocate(int $amount, array $weights): array {
    $total = array_sum($weights);
    $raw = array_map(fn ($w) => $amount * $w / $total, $weights);
    $alloc = array_map('floor', $raw);
    $left = $amount - array_sum($alloc);
    $rema = array_map(fn ($r, $a) => $r - $a, $raw, $alloc);
    arsort($rema);
    foreach (array_keys($rema) as $i) { if ($left-- <= 0) break; $alloc[$i]++; }
    return array_map('intval', $alloc);
}
// allocate(50000, [120000, 45000, 35000]) => [30000, 11250, 8750]
Enter fullscreen mode Exit fullscreen mode

Store the result on seller_orders.discount_alloc at checkout and never recompute it. If you recompute after a line is cancelled, your refunds and payouts drift from what the customer actually paid.

Plain cardboard boxes stacked on separate wheeled dollies in a fulfillment center, representing one order split into seller shipments
AI-generated image.

One payment, many fulfilments

Capture once on the parent order, then record each seller's share in a ledger. I covered that side in detail in multi-vendor marketplace payments, ledgers and payouts; the important link here is that every ledger entry points at a seller_order_id, never only the parent.

Reserve stock per seller line at checkout with a short hold, and release it if payment fails. The hold-and-expire pattern is the same one in preventing double booking with locks, holds and expiry.

Each seller order then moves independently: pending → accepted → packed → shipped → delivered. The accept_by timestamp drives a scheduled job that auto-cancels and refunds a seller order that was never accepted, without touching the other sellers.

Deriving the customer status

Do not store a parent status. Derive it:

Child statuses Customer sees
All pending or accepted Processing
Some shipped, some not Partially shipped
All shipped or delivered (ignoring cancelled) Shipped
All delivered (ignoring cancelled) Delivered
All cancelled or refunded Cancelled

A stored parent status always ends up contradicting its children after a partial cancellation. A derived one cannot.

Failure modes and the trade-off each fix costs

Failure Symptom Fix Cost of the fix
One seller cannot fulfil Whole order cancelled Cancel only that seller order and refund its share Customer gets a partial order; notify clearly
Coupon recomputed after cancellation Refund larger than paid share Freeze discount_alloc at checkout Slightly unfair edge cases on minimum-spend coupons
Carrier quote timeout Checkout spinner, abandoned carts Timeout plus seller flat-rate fallback Occasional under- or over-charged shipping
Duplicate submit Two parent orders idempotency_key unique on orders One extra column and client-generated key
Return outside one seller's window Support escalations Store return policy per seller order at checkout Policy snapshot per order
Seller ships late Customer blames the platform accept_by and ship-by SLAs with auto-cancel Sellers must adopt the dashboard

Implementation checklist

  • [ ] Parent orders for payment, seller_orders per seller and origin for fulfilment
  • [ ] Money in integer minor units everywhere
  • [ ] Parallel shipping quotes with a timeout and a flat-rate fallback
  • [ ] Largest-remainder discount allocation, stored at checkout
  • [ ] Stock holds per line, released on payment failure
  • [ ] Ledger entries reference seller_order_id
  • [ ] Per-seller state machine with an acceptance SLA
  • [ ] Customer status derived, never stored
  • [ ] Return and cancellation policy snapshotted per seller order
  • [ ] Tests for partial cancel, partial refund and one-seller timeout

Frequently asked questions

Should the customer see one order number or several?

Show one order number and group shipments under it by seller. Customers think in purchases. Give each seller order its own suffix (for example 10452-A, 10452-B) for support conversations.

How do I handle free shipping thresholds with several sellers?

Decide whether the threshold is per seller or platform-wide, and say so at checkout. Per-seller is easier to fund, because each seller pays for its own shipping. A platform-wide threshold means the platform subsidizes the difference, which has to be booked in the ledger.

What if one seller's item goes out of stock after payment?

Cancel that seller order or just the affected lines, refund the line amounts minus their allocated discount, and keep the rest of the purchase moving. Never hold the other sellers' shipments.

Can sellers use different carriers in one order?

Yes, and they usually should. Shipping is quoted and tracked per seller order, so each can use its own carrier integration. A strategy-per-carrier pattern keeps that code clean.

How do taxes work when sellers ship from different places?

Tax often depends on where goods ship from and to, so calculate it per seller order, not per cart. Rules vary by country, so confirm them with an accountant before launch.

Is this overkill for a marketplace MVP?

The parent and child split is not. It is cheap on day one and painful to retrofit. You can skip the SLA jobs and parallel quoting until you have real sellers.


Follow & Connect

Top comments (0)