DEV Community

Cover image for Multi-Vendor Marketplace Payments: Ledgers & Payouts
Muhammad Ali Mughal
Muhammad Ali Mughal

Posted on

Multi-Vendor Marketplace Payments: Ledgers & Payouts

Most first-version marketplaces I review track money with a vendor_balance column and UPDATE ... SET balance = balance + ?. It works until a partial refund lands after a payout, a chargeback arrives weeks later, or a webhook is delivered twice. Then nobody can explain why a vendor was paid more than they earned.

At Loop Origin we build marketplace and e-commerce platforms on Laravel, Vue and Node, and the payment side is where I spend the most review time. This is the architecture I use: split payments, a double-entry commission ledger, payouts with holds, refunds and chargebacks, idempotency and daily reconciliation.

How should a multi-vendor marketplace handle split payments and payouts?

Direct answer: Charge the buyer once, record every movement as balanced double-entry ledger lines (buyer funds, vendor payable, platform commission, processor fees), and pay vendors from their ledger balance after a hold period. Make every write idempotent, treat refunds and chargebacks as new reversing entries, and reconcile the ledger against processor reports daily.

The money flow, before any code

One order with items from three vendors is one charge but three obligations. The platform keeps a commission, absorbs or passes on processor fees, and owes each vendor the remainder once goods ship and the refund window closes.

Approach Who holds funds Refund handling Best fit Main risk
Single charge, payouts later (separate charges and transfers) Platform balance until transfer You reverse transfers or net against future payouts Multi-vendor carts, delayed fulfilment You carry vendor debt when refunds land after payout
Charge routed to one vendor (destination-style charge) Processor moves funds to the vendor at charge time Reverse the transfer and optionally refund the platform fee One vendor per order Awkward for carts that span vendors
Vendor is merchant of record (direct charge on the vendor account) Vendor Vendor's own account handles it Platforms that are mostly software Less control over buyer experience
Manual bank payouts from your own account Platform Entirely yours Regions without a marketplace product from your processor Licensing questions and heavy reconciliation

On Stripe Connect, row one maps to separate charges and transfers linked by a transfer_group; row two to destination charges, where a refund can pass reverse_transfer and refund_application_fee. The ledger below works with any model because it records what you owe, not what the processor did.

A double-entry ledger schema that survives audits

Every movement is a journal entry with two or more lines that sum to zero. Balances are derived from lines, never stored as the source of truth.

CREATE TABLE ledger_accounts (
    id           BIGSERIAL PRIMARY KEY,
    code         TEXT NOT NULL UNIQUE,      -- e.g. 'vendor:812:payable'
    type         TEXT NOT NULL CHECK (type IN ('asset','liability','revenue','expense')),
    currency     CHAR(3) NOT NULL
);

CREATE TABLE journal_entries (
    id              BIGSERIAL PRIMARY KEY,
    idempotency_key TEXT NOT NULL UNIQUE,   -- e.g. 'order:5521:captured'
    event_type      TEXT NOT NULL,          -- captured, refunded, payout, dispute...
    reference_type  TEXT NOT NULL,
    reference_id    BIGINT NOT NULL,
    occurred_at     TIMESTAMPTZ NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE journal_lines (
    id          BIGSERIAL PRIMARY KEY,
    entry_id    BIGINT NOT NULL REFERENCES journal_entries(id),
    account_id  BIGINT NOT NULL REFERENCES ledger_accounts(id),
    amount      BIGINT NOT NULL,            -- minor units; debit +, credit -
    currency    CHAR(3) NOT NULL
);
CREATE INDEX ON journal_lines (account_id);
Enter fullscreen mode Exit fullscreen mode

Three rules matter most: integer minor units, never floats; append-only lines, with corrections as new entries; and a unique idempotency_key that makes retries safe.

For a $100.00 order with two vendors, 10% commission and a $3.20 processor fee absorbed by the platform, the capture entry looks like this:

Account Debit Credit
processor_clearing (asset) 10,000
vendor:812:payable (liability) 5,400
vendor:907:payable (liability) 3,600
platform:commission (revenue) 1,000
processor_fees (expense) 320
processor_clearing (asset) 320

Debits equal credits at 10,320. Vendor 812 sold $60 and is owed $54; vendor 907 sold $40 and is owed $36. The fee reduces what the processor settles, so it also credits clearing.

A stack of gold coins on a dark wooden desk, representing ledger balances in a marketplace

The posting service: one transaction, balanced or nothing

final class LedgerPoster
{
    /** @param array<array{account:string, amount:int}> $lines */
    public function post(string $key, string $type, Model $ref, array $lines, string $currency): JournalEntry
    {
        if (array_sum(array_column($lines, 'amount')) !== 0) {
            throw new UnbalancedEntry($key);
        }

        return DB::transaction(function () use ($key, $type, $ref, $lines, $currency) {
            $entry = JournalEntry::firstOrCreate(
                ['idempotency_key' => $key],
                ['event_type' => $type, 'reference_type' => $ref->getMorphClass(),
                 'reference_id' => $ref->getKey(), 'occurred_at' => now()]
            );

            if (! $entry->wasRecentlyCreated) {
                return $entry; // replay: already posted, do nothing
            }

            foreach ($lines as $line) {
                $entry->lines()->create([
                    'account_id' => LedgerAccount::idFor($line['account'], $currency),
                    'amount'     => $line['amount'],
                    'currency'   => $currency,
                ]);
            }
            return $entry;
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Two concurrent replays can both miss on firstOrCreate; the unique index makes one fail, so catch that violation and re-read the entry. The same pattern protects carts, as in How to Prevent Double Booking: Locks, Holds and Expiry.

Webhooks and idempotency in both directions

Idempotency has to work outbound and inbound.

  • Outbound: every charge, transfer, refund or payout call sends a key derived from your IDs, such as transfer:order:5521:vendor:812. Stripe accepts an Idempotency-Key header; most processors have an equivalent.
  • Inbound: insert each webhook event ID into a uniquely constrained processed_events table before acting. Processors retry on timeouts, so duplicates are normal.
  • Order independence: a charge.refunded event can beat your own refund job. Key entries by the processor object ID so the second path is a no-op.

Process webhooks on a queue, return 2xx fast, and let failed jobs retry. My checklist for that setup is in Running Laravel Queues in Production.

Payouts, holds and negative balances

I credit sales to a vendor:{id}:held account and move them to vendor:{id}:payable with a scheduled release entry once the order is delivered and the return window closes. The payout job then:

  1. Locks the vendor row (SELECT ... FOR UPDATE) so payout jobs cannot overlap.
  2. Computes the payable balance from ledger lines and skips if it is negative or below the minimum.
  3. Posts a payout entry (debit payable, credit payout_in_transit) keyed payout:{vendor}:{period}.
  4. Calls the processor with the same key, then settles payout_in_transit on the paid event or reverses it on failure.

A refund after payout makes the vendor's payable negative. Do not hide it: net it against future sales, and define in your vendor agreement when you invoice or pause the account.

Refunds and chargebacks are new entries

A partial refund of $20 on vendor 812's item reverses the proportional slice: debit vendor 812 payable 1,800, debit commission 200, credit processor clearing 2,000. Whether the processor fee is returned depends on your processor and its terms, so record what the processor report says, not what you assume.

A chargeback is two events. When the dispute opens, move the amount and any dispute fee into disputes_pending and freeze the vendor's share. Win, and you reverse it; lose, and the loss lands on the vendor or platform per your policy.

Daily reconciliation

Every day, import the processor's balance transactions or settlement report and match totals:

Check Ledger side Processor side Typical cause of mismatch
Gross captured Sum of capture entries Charges in the report Webhook never processed
Fees processor_fees lines Fee column Fee changed or refund fee not returned
Payouts Settled payout entries Payouts in the report Failed payout not reversed
Clearing balance processor_clearing Reported balance Any of the above, or currency conversion

Unmatched rows go to a review queue with object IDs from both sides. A ledger silently off by cents each day becomes an unexplainable gap at month end.

Failure modes I see in reviews

  • Floats for money. Rounding drifts across splits. Use integers and assign remainders to one line.
  • Commission computed at payout time. A rate change rewrites old orders. Freeze the rate at capture.
  • Mutable balance columns as truth. Fine as a cache, fatal as the source.
  • Refund handlers that assume no payout happened. Always handle negative balances.
  • Multi-currency entries. One currency per entry; post FX as its own movement.

For the processor-abstraction side, I use the strategy pattern from Strategy and Factory Patterns for Payment and Courier Code.

Implementation checklist

  • [ ] Amounts stored as integer minor units, with currency on every line
  • [ ] Journal entries balanced before commit and lines append-only
  • [ ] Unique idempotency key on entries, outbound API calls and inbound webhook IDs
  • [ ] Commission rate frozen per order line at capture
  • [ ] Held and payable accounts separated, with scheduled release entries
  • [ ] Payout job locks per vendor and handles negative balances
  • [ ] Refunds and disputes posted as reversing entries, never edits
  • [ ] Daily reconciliation against processor reports, with a review queue

If you are planning a marketplace build, our e-commerce website development and custom web application development teams start with this ledger design. For the business view, read Multi-Vendor Marketplace Development in Pakistan, or contact us.

Frequently asked questions

Do I need double-entry accounting for a small marketplace?

If you pay more than a handful of vendors, yes. It costs three tables and one service, far less than migrating after the first disputed payout.

Should balances be stored or calculated?

Calculate them from ledger lines. Cache them in a column or materialized view for speed, and rebuild the cache from the lines whenever it is in doubt.

How long should the payout hold be?

Long enough to cover delivery plus your return window. Many marketplaces also hold new vendors longer until they build history. Base it on your refund data, not a default.

What happens if a chargeback arrives after the vendor was paid?

Post it as a dispute entry. The vendor's payable goes negative and is recovered from future sales or invoiced according to your agreement.

Can I use the processor's dashboard as my ledger?

No. It records what the processor did, not what you owe each vendor. Reconcile against it instead.


Follow & Connect

Top comments (4)

Collapse
 
banxta profile image
Banxta •

The "manual bank payouts from your own account" row has a third risk next to licensing: the account terms. Revolut Business says you "can't hold, exchange, transfer, or manage client funds" in the account, whatever your business type. Starling bars transactions with client money, and Wise's acceptable use policy lists marketplaces from outside the EEA, the EU and the US. So the held and payable ledger can be spotless and the account can still be closed for doing exactly what it was built for.

Collapse
 
muhammadalimughal profile image
Muhammad Ali Mughal •

Great point, and one I underplayed in that row. A perfectly clean held/payable ledger doesn't help if the account itself isn't allowed to hold or route other people's money, and a bank can close it with very little notice. That's another reason I'd only treat manual payouts as a short bridge at very low volume. Once you're actually holding seller funds, a regulated split-payment provider (Stripe Connect, Adyen for Platforms, Mangopay and similar) or a proper client-money arrangement is the safer route. Thanks for adding the account-terms angle.

Collapse
 
banxta profile image
Banxta •

Agreed. With split payments the sellers' money never lands in your own bank account, only your commission does, so the account-terms problem mostly goes away too. One thing I'd check before building on any of them is which sellers they'll take. Stripe, for one, publishes a restricted businesses list, so it's worth reading it against your sellers' categories.

Thread Thread
 
muhammadalimughal profile image
Muhammad Ali Mughal •

Good call. Country coverage is the other one to map early: Connect-style providers only pay out to sellers in the countries they support, so a marketplace with sellers in unsupported regions can end up needing a second provider or a different payout model. Checking both the restricted-business list and the supported payout countries against your real seller base before you build saves a painful migration later.