Skip to content

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:

  1. Create their SOAMTAR account (manually, or later through a SOAMTAR onboarding API, on the roadmap).
  2. Get their merchant_id.
  3. Store it with your vendor user.
php
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 ​

php
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:

php
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 ​

php
$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 amount

Refunding 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:

php
$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 ​

PitfallSolution
Splitting to an inactive vendorCheck Vendor::is_active before createSplits
Splitting before the payment is confirmedFine: splits are created with is_settled = false and distributed on success
Sum of splits ≠ totalFine if below the total (the difference stays with the platform); refused if above
A vendor's merchant_id changesPrevent it (immutable once the first sale is made)
Commission varies per vendorStore commission_pct on the Vendor and read it for each split

Tests ​

php
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);
});

SOAMTAR Payment API Documentation.