Marketplace multi-vendeurs
Une plateforme où plusieurs vendeurs (par exemple des commerçants de Freetown, Bo et Kenema) encaissent via vous, avec une commission plateforme prélevée à chaque vente.
Architecture
[Client] paie ──> [Plateforme = vous] ──split──> [Vendeur A : 80 %]
└─> [Vendeur B : 15 %]
└─> [Plateforme : 5 % de commission]Chaque vendeur est un marchand SOAMTAR à part entière, onboardé séparément (KYC propre). Vous, plateforme, êtes le marchand « facturant » : vous encaissez la transaction puis splittez vers les vendeurs.
Onboarding vendor
Quand un vendeur s'inscrit sur votre plateforme, vous devez :
- Créer son compte SOAMTAR (manuellement, ou plus tard via une API d'onboarding SOAMTAR, prévue).
- Récupérer son
merchant_id. - Le stocker associé à votre user vendor.
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();
});Paiement avec 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. Encaisser le total via la plateforme
$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. Pré-créer les splits (avant même la confirmation paiement)
// SOAMTAR les distribuera au settlement dès que la transaction est success.
$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']);
}
}Concurrence sûre
POST/v1/payments/{id}/splits est sérialisé côté serveur : si deux requêtes essaient d'enregistrer des splits en parallèle sur la même transaction parent, la seconde voit les lignes de la première avant de valider sa propre somme. Aucun sur-split n'est possible.
Webhook : settle automatique au payment.success
À la réception du webhook payment.success, vous n'avez rien à faire pour distribuer aux vendors : SOAMTAR crédite automatiquement les balances des bénéficiaires au settlement.
Vous devez juste :
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']);
// Notifier chaque vendor de sa vente
foreach ($order->cart as $vendorId => $items) {
VendorNotifier::dispatch(Vendor::find($vendorId), $order, $items);
}
}
return response('ok');
}Remboursements dans une marketplace
Remboursez le client avec POST/v1/refunds sur le paiement d'origine. SOAMTAR recrédite le montant sur le portefeuille Orange Money du payeur et le débite de votre solde ; le résultat arrive par refund.success ou refund.failed. Voir la référence Remboursements.
Remboursement total
$soamtar->refunds()->create([
'transaction_id' => $order->soamtar_id, // id SOAMTAR du paiement
'reason' => 'customer_request',
'description' => "Remboursement {$order->reference}",
], idempotencyKey: "refund:{$order->id}"); // sans amount = tout le montant restantRemboursement d'un seul vendeur
Si un vendeur n'a pas livré et que vous ne remboursez que sa part, émettez un remboursement partiel de cette part et récupérez-la auprès du vendeur selon les conditions de votre plateforme :
$soamtar->refunds()->create([
'transaction_id' => $order->soamtar_id,
'amount' => '120.00', // la part du vendeur, en leones
'reason' => 'vendor_not_delivered',
], idempotencyKey: "refund:{$order->id}:{$vendor->id}");Pièges à éviter
| Piège | Solution |
|---|---|
| Splitter un vendor non-actif | Vérifier Vendor::is_active avant le createSplits |
| Splitter avant confirmation paiement | OK : les splits sont créés en is_settled = false et distribués au succès |
| Somme des splits ≠ total | Pas grave si < total (la diff reste à la plateforme), refusé si > total |
Vendor change de merchant_id | Empêcher (immutable une fois la première vente passée) |
| Commission variable selon vendor | Stocker commission_pct sur Vendor, lire à chaque 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);
});