DEV3 Technologies home

Vendor payout ledger

An append-only ledger where sales, commissions and payouts are signed entries, so a vendor's balance is always a sum and never a stored guess.

LaravelDatabaseledgerpayoutsmarketplacebalance

Code

<?php

namespace App\Payouts;

use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use RuntimeException;

final class VendorLedger
{
    public function __construct(private readonly int $commissionBps = 1000) {} // 10.00%

    /** Order delivered: credit the sale, debit the platform's cut. */
    public function recordSale(int $vendorId, int $grossCents, string $ref): void
    {
        $fee = intdiv($grossCents * $this->commissionBps + 5000, 10000);

        DB::transaction(function () use ($vendorId, $grossCents, $fee, $ref) {
            $this->add($vendorId, 'sale', $grossCents, $ref);
            $this->add($vendorId, 'commission', -$fee, $ref);
        });
    }

    public function balance(int $vendorId): int
    {
        return (int) DB::table('ledger_entries')->where('vendor_id', $vendorId)->sum('amount');
    }

    /** Pay out everything owed; the lock stops two payout runs from overlapping. */
    public function payout(int $vendorId, string $ref, int $minimumCents = 1000): int
    {
        return DB::transaction(function () use ($vendorId, $ref, $minimumCents) {
            DB::table('vendors')->where('id', $vendorId)->lockForUpdate()->first();
            $owed = $this->balance($vendorId);

            if ($owed < $minimumCents) {
                throw new RuntimeException('Balance is below the payout minimum.');
            }
            $this->add($vendorId, 'payout', -$owed, $ref);

            return $owed;
        });
    }

    private function add(int $vendorId, string $type, int $amount, string $ref): void
    {
        try {
            DB::table('ledger_entries')->insert([
                'vendor_id' => $vendorId, 'type' => $type, 'amount' => $amount, 'ref' => $ref,
            ]);
        } catch (QueryException $e) {
            if (! str_contains($e->getMessage(), 'nique')) {
                throw $e; // a duplicate (vendor, type, ref) is a harmless retry
            }
        }
    }
}

How it works

What it solves

Storing a running balance column on a vendor looks simple, but it drifts: a retry, a refund or a crash between two updates leaves the number wrong and nobody can explain why. A ledger flips the model. Money movements are rows, and the balance is always the sum of those rows.

How it works

Every entry is a signed integer of cents: sales are positive, commissions, refunds and payouts are negative. recordSale() writes the gross sale and the platform fee as two entries inside a transaction. The fee uses basis points with half-up integer rounding, so 10% is 1000 and no float is involved.

A unique key on vendor, type and reference makes writes idempotent. If a webhook is delivered twice, the second insert hits the key, the helper recognises the duplicate and ignores it, so nothing is double counted.

payout() locks the vendor row, reads the current balance and writes one negative entry for the full amount. The lock stops two scheduled runs from paying the same money twice, and a minimum threshold avoids tiny transfers.

Adapting it

  • Add hold_until for a settlement period and sum only released rows into the payable balance.
  • Write refunds as negative entries linked to the same reference prefix.
  • Reconcile by comparing the ledger sum against your payment provider's statement.

Pitfalls

  • Never update or delete ledger rows; correct mistakes with a new opposite entry.
  • Duplicate detection by message text is brittle across databases; check the SQLSTATE code if you support several.

How to use it

  1. Copy the migration and run php artisan migrate.
  2. Put VendorLedger.php in app/Payouts/.
  3. When an order is delivered: app(VendorLedger::class)->recordSale($vendor->id, $subOrder->total, "order-{$subOrder->id}");
  4. Show the balance: app(VendorLedger::class)->balance($vendor->id).
  5. In a scheduled command call payout($vendor->id, "run-".now()->format('Ymd')), then send the money through your provider.

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