DEV3 Technologies home

Split an order per vendor

Group cart lines by vendor, create one sub-order each and share the order discount across them using integer largest-remainder allocation.

LaravelDatabasemarketplaceordersdiscountallocation

Code

<?php

namespace App\Orders;

use App\Models\Order;
use App\Models\SubOrder;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

final class OrderSplitter
{
    /**
     * @param  Collection<int, array{vendor_id:int, product_id:int, qty:int, unit_price:int}>  $lines
     * @param  int  $discount  order-level discount in cents
     */
    public function split(Order $order, Collection $lines, int $discount = 0): Collection
    {
        $groups = $lines->groupBy('vendor_id');
        $subtotals = $groups->map(fn ($g) => $g->sum(fn ($l) => $l['qty'] * $l['unit_price']));
        $shares = $this->allocate($discount, $subtotals->all());

        return DB::transaction(fn () => $groups->map(function ($group, $vendorId) use ($order, $subtotals, $shares) {
            $sub = SubOrder::create([
                'order_id' => $order->id,
                'vendor_id' => $vendorId,
                'subtotal' => $subtotals[$vendorId],
                'discount' => $shares[$vendorId],
                'total' => $subtotals[$vendorId] - $shares[$vendorId],
                'status' => 'pending',
            ]);
            $sub->items()->createMany($group->map(fn (array $line) => Arr::only($line, ['product_id', 'qty', 'unit_price']))->all());

            return $sub;
        }));
    }

    /**
     * Largest-remainder method: split $amount in proportion to $weights so
     * the parts always add up to exactly $amount (no lost or extra cent).
     *
     * @param  array<int,int>  $weights
     * @return array<int,int>
     */
    private function allocate(int $amount, array $weights): array
    {
        $total = array_sum($weights);
        $out = $rema = [];

        foreach ($weights as $key => $w) {
            $exact = $total > 0 ? $amount * $w / $total : 0;
            $out[$key] = (int) floor($exact);
            $rema[$key] = $exact - $out[$key];
        }

        arsort($rema);
        $left = $amount - array_sum($out);
        foreach (array_keys($rema) as $key) {
            if ($left-- <= 0) {
                break;
            }
            $out[$key]++;
        }

        return $out;
    }
}

How it works

What it solves

In a marketplace the customer pays once, but every seller needs their own order to pack, ship, refund and be paid for. Splitting the cart is easy; splitting an order-level discount without losing or inventing a cent is the part people get wrong.

How it works

split() groups the cart lines by vendor, computes each group's subtotal in integer cents, and asks allocate() to divide the discount in proportion to those subtotals. It then creates one SubOrder per vendor with its own subtotal, discount share and total, plus its line items, all inside a transaction so a failure cannot leave half an order.

allocate() uses the largest-remainder method. Each vendor first receives the floor of its exact share. The few cents left over are then handed out one at a time to the vendors whose fractional parts were largest. The parts therefore always sum to exactly the original discount, and the result is deterministic, which matters for reconciliation and refunds.

Shipping fees, taxes and coupons funded by the platform can be allocated with the same helper by passing different weights.

Adapting it

  • Weight shipping equally per vendor instead of by subtotal by passing an array of ones.
  • Add a platform commission column computed from each sub-order's total.
  • Create the vendor payout ledger entries when a sub-order is delivered.

Pitfalls

  • Keep every amount as an integer; mixing floats and cents reintroduces rounding gaps.
  • Refunds should reference the sub-order, not the parent, so each vendor's balance stays correct.

How to use it

  1. Create sub_orders (order_id, vendor_id, subtotal, discount, total, status) with an items relation holding product_id, qty, unit_price.
  2. Put OrderSplitter.php in app/Orders/.
  3. After creating the parent order, build $lines from the cart and call app(OrderSplitter::class)->split($order, $lines, $discountCents);
  4. Notify each vendor from the returned collection of sub-orders.
  5. Prices must already be in cents.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets