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);
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.
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;
});
}
}
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 anIdempotency-Keyheader; most processors have an equivalent. -
Inbound: insert each webhook event ID into a uniquely constrained
processed_eventstable before acting. Processors retry on timeouts, so duplicates are normal. -
Order independence: a
charge.refundedevent 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:
- Locks the vendor row (
SELECT ... FOR UPDATE) so payout jobs cannot overlap. - Computes the payable balance from ledger lines and skips if it is negative or below the minimum.
- Posts a
payoutentry (debit payable, creditpayout_in_transit) keyedpayout:{vendor}:{period}. - Calls the processor with the same key, then settles
payout_in_transiton 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
- Website: Loop Origin
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)

Top comments (0)