Multi-vendor marketplace
A platform where several sellers (say, traders from Freetown, Bo and Kenema) get paid through you, with a platform commission taken on every sale.
Architecture
[Customer] pays ──> [Platform = you] ──split──> [Vendor A: 80%]
└─> [Vendor B: 15%]
└─> [Platform: 5% commission]Each vendor is a SOAMTAR merchant in their own right, onboarded separately (with their own KYC). You, the platform, are the "billing" merchant: you collect the transaction, then split it to the vendors.
Vendor onboarding
When a vendor signs up on your platform, you must:
- Create their SOAMTAR account (manually, or later through a SOAMTAR onboarding API, on the roadmap).
- Get their
merchant_id. - Store it with your vendor user.
Schema::create('vendors', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name');
$table->uuid('soamtar_merchant_id')->unique();
$table->boolean('is_active')->default(true);
$table->timestamps();
});Payment with splits
class MarketplaceCheckoutController
{
public function pay(CheckoutRequest $request, SoamtarClient $soamtar)
{
$cart = $request->validatedCart(); // ['vendor_id' => $items[]]
$totalAmount = collect($cart)->sum(fn ($items) => collect($items)->sum('price'));
// 1. Collect the total through the platform
$order = Order::create([
'reference' => 'MKT-'.strtoupper(Str::random(8)),
'total' => $totalAmount,
'status' => 'pending',
]);
$payment = $soamtar->payments()->create([
'merchant_ref' => $order->reference,
'amount' => $totalAmount,
'currency' => 'SLE',
'provider' => 'orange_money_sl',
'payer' => $request->user()->only(['phone', 'email', 'name']),
'metadata' => ['order_id' => (string) $order->id],
'return_url' => route('marketplace.success', $order),
'cancel_url' => route('marketplace.cancel', $order),
], idempotencyKey: $order->id);
// 2. Register the splits right away (before the payment is confirmed).
// SOAMTAR distributes them at settlement once the transaction succeeds.
$splits = collect($cart)->map(function ($items, $vendorId) use ($order) {
$vendor = Vendor::findOrFail($vendorId);
$vendorTotal = collect($items)->sum('price');
$platformFeePct = 0.05; // 5% commission
return [
'beneficiary_merchant_id' => $vendor->soamtar_merchant_id,
'amount' => $vendorTotal,
'fee_amount' => round($vendorTotal * $platformFeePct, 2),
'description' => "Order {$order->reference} - Vendor {$vendor->name}",
];
})->values()->all();
$soamtar->payments()->createSplits(
$payment['data']['id'],
$splits,
idempotencyKey: $order->id.':splits',
);
$order->update([
'soamtar_id' => $payment['data']['id'],
'soamtar_reference' => $payment['data']['reference'],
]);
return redirect()->away($payment['data']['payment_url']);
}
}Concurrency safety
POST /v1/payments/{id}/splits is serialised on the server: if two requests try to register splits on the same parent transaction at the same time, the second one sees the first one's rows before validating its own total. Over-splitting is impossible.
Webhook: automatic settlement on payment.success
When you receive the payment.success webhook, you have nothing to do to pay the vendors: SOAMTAR credits the beneficiaries' balances at settlement.
You only need to:
public function handle(Request $request, WebhookVerifier $verifier)
{
$event = $verifier->verify($request);
if ($event['event'] !== 'payment.success') return response('skip');
$orderId = $event['data']['metadata']['order_id'] ?? null;
$order = Order::find($orderId);
if ($order && $order->status === 'pending') {
$order->update(['status' => 'paid']);
// Tell each vendor about their sale
foreach ($order->cart as $vendorId => $items) {
VendorNotifier::dispatch(Vendor::find($vendorId), $order, $items);
}
}
return response('ok');
}Refunds in a marketplace
Refund the customer with POST /v1/refunds on the original payment. SOAMTAR credits the amount back to the payer's Orange Money wallet and debits it from your balance; the outcome arrives as refund.success or refund.failed. See the Refunds reference.
Full refund
$soamtar->refunds()->create([
'transaction_id' => $order->soamtar_id, // SOAMTAR id of the payment
'reason' => 'customer_request',
'description' => "Refund {$order->reference}",
], idempotencyKey: "refund:{$order->id}"); // no amount = full remaining amountRefunding one vendor only
If one vendor failed to deliver and you only want to refund their share, issue a partial refund for that share and recover it from the vendor according to your platform terms:
$soamtar->refunds()->create([
'transaction_id' => $order->soamtar_id,
'amount' => '120.00', // the vendor's share, in leones
'reason' => 'vendor_not_delivered',
], idempotencyKey: "refund:{$order->id}:{$vendor->id}");Pitfalls to avoid
| Pitfall | Solution |
|---|---|
| Splitting to an inactive vendor | Check Vendor::is_active before createSplits |
| Splitting before the payment is confirmed | Fine: splits are created with is_settled = false and distributed on success |
| Sum of splits ≠ total | Fine if below the total (the difference stays with the platform); refused if above |
A vendor's merchant_id changes | Prevent it (immutable once the first sale is made) |
| Commission varies per vendor | Store commission_pct on the Vendor and read it for each split |
Tests
it('creates splits for each vendor when paying a multi-vendor cart', function () {
Http::fake([
'app.so-amtar.com/v1/payments' => Http::response(['data' => ['id' => 'tx-1', 'reference' => 'TXN-X', 'payment_url' => 'https://...']], 201),
'app.so-amtar.com/v1/payments/tx-1/splits' => Http::response(['data' => [/* 2 splits */]], 201),
]);
$vendorA = Vendor::factory()->create(['soamtar_merchant_id' => 'vendor-a-uuid']);
$vendorB = Vendor::factory()->create(['soamtar_merchant_id' => 'vendor-b-uuid']);
$response = $this->actingAs(User::factory()->create())
->postJson('/marketplace/checkout', [
'cart' => [
$vendorA->id => [['price' => '400.00']],
$vendorB->id => [['price' => '600.00']],
],
]);
Http::assertSent(fn ($req) => str_ends_with($req->url(), '/splits') && count($req['splits']) === 2);
});