E-commerce simple
Une boutique en ligne (par exemple une boutique de vêtements à Freetown) avec panier, checkout et confirmation de commande.
Schéma
[Client] [Votre boutique] [SOAMTAR]
| ajoute au panier | |
|-------------------------->| |
| clic « Payer » | |
|-------------------------->| POST /v1/payments |
| |---------------------------->|
| |<----------------------------|
| | 201 + payment_url |
| redirection payment_url | |
|<--------------------------| |
| saisit son numéro Orange Money, confirme avec son PIN |
|-------------------------------------------------------->|
| redirection return_url | |
|<--------------------------------------------------------|
| | webhook payment.success |
| |<----------------------------|
| page de confirmation | |
|<--------------------------| |Modèle Order
php
Schema::create('orders', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('reference')->unique();
$table->foreignUuid('user_id');
$table->string('checkout_token')->unique(); // un par page panier (champ caché), unique
$table->decimal('amount', 15, 2); // leones, 2 décimales
$table->enum('status', ['pending', 'paid', 'review', 'failed', 'cancelled', 'expired', 'refunded'])->default('pending');
$table->uuid('soamtar_id')->nullable();
$table->string('soamtar_reference')->nullable(); // TXN-...
$table->jsonb('items');
$table->timestamps();
});Initiation du paiement
php
use App\Services\SOAMTAR\SoamtarClient;
use Illuminate\Support\Str;
class CheckoutController extends Controller
{
public function pay(CheckoutRequest $request, SoamtarClient $soamtar)
{
// Un double-clic ou un renvoi envoie le même checkout_token (champ caché
// généré avec la page panier) : on retrouve la même commande, donc la même
// Idempotency-Key ci-dessous, et SOAMTAR ne crée qu'une transaction.
$order = Order::firstOrCreate(
['checkout_token' => $request->validated('checkout_token'), 'user_id' => $request->user()->id],
[
'reference' => 'ORD-'.strtoupper(Str::random(8)),
'amount' => Cart::totalFor($request->validated('items')), // calculé côté serveur, jamais envoyé par le navigateur
'items' => $request->validated('items'),
],
);
abort_unless($order->status === 'pending', 409, 'Commande déjà traitée');
$response = $soamtar->payments()->create([
'merchant_ref' => $order->reference, // unique côté merchant
'amount' => $order->amount,
'currency' => 'SLE',
'provider' => $request->validated('provider'), // orange_money_sl
'payer' => [
'phone' => $request->user()->phone, // +232XXXXXXXX
'email' => $request->user()->email,
'name' => $request->user()->name,
],
'description' => "Commande {$order->reference}",
'return_url' => route('orders.success', $order),
'cancel_url' => route('orders.cancel', $order),
'metadata' => ['order_id' => (string) $order->id],
], idempotencyKey: $order->id); // = order UUID, déterministe
$order->update([
'soamtar_id' => $response['data']['id'],
'soamtar_reference' => $response['data']['reference'],
]);
return redirect()->away($response['data']['payment_url']);
}
}Webhook handler
php
class SoamtarWebhookController
{
public function __invoke(Request $request, WebhookVerifier $verifier)
{
try {
$event = $verifier->verify($request);
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 401);
}
// Idempotence : une transaction envoie plusieurs événements (pending, success…),
// la clé d'unicité est donc le type d'événement + l'id de transaction.
$eventKey = $event['event'].':'.$event['data']['id'];
if (SoamtarWebhookLog::where('event_key', $eventKey)->exists()) {
return response()->json(['status' => 'duplicate'], 200);
}
DB::transaction(function () use ($event, $eventKey) {
SoamtarWebhookLog::create([
'event_key' => $eventKey,
'event_type' => $event['event'],
'payload' => $event,
]);
$orderId = $event['data']['metadata']['order_id'] ?? null;
if (! $orderId) return;
$order = Order::find($orderId);
if (! $order) return;
$newStatus = match ($event['event']) {
'payment.success' => 'paid',
'payment.failed' => 'failed',
'payment.cancelled' => 'cancelled',
'payment.expired' => 'expired',
default => null,
};
if ($newStatus === 'paid' && (
bccomp((string) $event['data']['amount'], (string) $order->amount, 2) !== 0
|| $event['data']['currency'] !== 'SLE'
)) {
$newStatus = 'review'; // montant ou devise différents : ne jamais expédier, vérifier à la main
}
if ($newStatus !== null && $order->status === 'pending') {
$order->update(['status' => $newStatus]);
if ($newStatus === 'paid') {
FulfillOrderJob::dispatch($order);
}
}
});
return response()->json(['ok' => true]);
}
}Page de retour
php
Route::get('/orders/{order}/success', function (Order $order) {
abort_if($order->user_id !== auth()->id(), 403);
// L'utilisateur arrive sur cette page APRÈS le paiement.
// Le webhook peut être arrivé ou pas (race possible).
// Dans le doute, on vérifie le statut côté SOAMTAR.
if ($order->status === 'pending' && $order->soamtar_id) {
$soamtar = app(SoamtarClient::class);
$response = $soamtar->payments()->verify($order->soamtar_id);
// Le webhook arrivera dans tous les cas, mais on actualise pour l'UI
}
return view('orders.success', ['order' => $order->fresh()]);
});Cron de réconciliation
php
class ReconcilePendingOrders extends Command
{
protected $signature = 'orders:reconcile';
public function handle(SoamtarClient $soamtar)
{
Order::where('status', 'pending')
->where('created_at', '<', now()->subMinutes(30))
->whereNotNull('soamtar_id')
->each(function (Order $order) use ($soamtar) {
$response = $soamtar->payments()->verify($order->soamtar_id);
$remoteStatus = $response['data']['status'];
$newStatus = match ($remoteStatus) {
'success' => 'paid',
'failed' => 'failed',
'cancelled' => 'cancelled',
'expired' => 'expired',
default => null,
};
if ($newStatus) {
$order->update(['status' => $newStatus]);
}
});
}
}php
// routes/console.php
Schedule::command('orders:reconcile')->everyTenMinutes();Cas limites
| Cas | Solution |
|---|---|
| Webhook arrive avant le redirect du payeur | OK : le payeur voit la page de succès avec le statut déjà à jour |
| Webhook arrive après redirect (race) | OK : la page de succès appelle verify en secours |
| Double-clic sur "Payer" | Même checkout_token → même commande → même Idempotency-Key ($order->id) → 1 seule transaction. Deux clics simultanés : l'index unique bloque la seconde commande, ou SOAMTAR répond 409 idempotency_conflict pendant que la première requête s'exécute |
| Même webhook délivré plusieurs fois | event_key UNIQUE → traité 1 seule fois |
| Le client ignore la demande Orange Money sur son téléphone | SOAMTAR envoie payment.expired après expires_at |
Tests
php
it('creates an order and redirects to payment_url', function () {
Http::fake([
'app.so-amtar.com/*' => Http::response(['data' => [
'id' => 'fake-uuid',
'reference' => 'TXN-FAKE12345',
'payment_url' => 'https://app.so-amtar.com/pay/checkout/fake-uuid',
]], 201),
]);
$response = $this->actingAs(User::factory()->create())
->post('/checkout/pay', [
'checkout_token' => 'cart-7f3a',
'items' => [['sku' => 'A1', 'qty' => 1]],
'provider' => 'orange_money_sl',
]);
$response->assertRedirect('https://app.so-amtar.com/pay/checkout/fake-uuid');
$this->assertDatabaseHas('orders', ['soamtar_id' => 'fake-uuid', 'status' => 'pending']);
});