Skip to content

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 ​

CasSolution
Webhook arrive avant le redirect du payeurOK : 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 foisevent_key UNIQUE → traité 1 seule fois
Le client ignore la demande Orange Money sur son téléphoneSOAMTAR 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']);
});

SOAMTAR Payment API Documentation.