Simple e-commerce
An online shop (for example a clothing store in Freetown) with a cart, a checkout and an order confirmation.
Flow
[Customer] [Your shop] [SOAMTAR]
| adds to cart | |
|-------------------------->| |
| clicks "Pay" | |
|-------------------------->| POST /v1/payments |
| |---------------------------->|
| |<----------------------------|
| | 201 + payment_url |
| redirect to payment_url | |
|<--------------------------| |
| enters Orange Money number, confirms with PIN |
|-------------------------------------------------------->|
| redirect to return_url | |
|<--------------------------------------------------------|
| | webhook payment.success |
| |<----------------------------|
| confirmation page | |
|<--------------------------| |Order model
php
Schema::create('orders', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('reference')->unique();
$table->foreignUuid('user_id');
$table->decimal('amount', 15, 2); // leones, 2 decimals
$table->enum('status', ['pending', 'paid', 'failed', 'cancelled', 'expired', 'refunded'])->default('pending');
$table->uuid('soamtar_id')->nullable();
$table->string('soamtar_reference')->nullable(); // TXN-...
$table->jsonb('items');
$table->timestamps();
});Starting the payment
php
use App\Services\SOAMTAR\SoamtarClient;
use Illuminate\Support\Str;
class CheckoutController extends Controller
{
public function pay(CheckoutRequest $request, SoamtarClient $soamtar)
{
$order = Order::create([
'reference' => 'ORD-'.strtoupper(Str::random(8)),
'user_id' => $request->user()->id,
'amount' => $request->validated('amount'),
'items' => $request->validated('items'),
]);
$response = $soamtar->payments()->create([
'merchant_ref' => $order->reference, // unique on your side
'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' => "Order {$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, deterministic
$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);
}
// Idempotency: one transaction sends several events (pending, success…),
// so the unique key is the event type + the transaction id.
$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 !== null && $order->status === 'pending') {
$order->update(['status' => $newStatus]);
if ($newStatus === 'paid') {
FulfillOrderJob::dispatch($order);
}
}
});
return response()->json(['ok' => true]);
}
}Return page
php
Route::get('/orders/{order}/success', function (Order $order) {
abort_if($order->user_id !== auth()->id(), 403);
// The customer lands here AFTER paying.
// The webhook may or may not have arrived yet (race).
// When in doubt, ask SOAMTAR for the status.
if ($order->status === 'pending' && $order->soamtar_id) {
$soamtar = app(SoamtarClient::class);
$response = $soamtar->payments()->verify($order->soamtar_id);
// The webhook will arrive anyway; this only refreshes the page
}
return view('orders.success', ['order' => $order->fresh()]);
});Reconciliation cron
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();Edge cases
| Case | Solution |
|---|---|
| Webhook arrives before the customer is redirected | Fine: the success page already shows the updated status |
| Webhook arrives after the redirect (race) | Fine: the success page calls verify as a fallback |
| Double click on "Pay" | Idempotency-Key = $order->id (deterministic) → a single transaction |
| Same webhook delivered several times | Unique event_key → processed once |
| Customer ignores the Orange Money request on their phone | SOAMTAR sends payment.expired after 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', [
'amount' => '150.00',
'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']);
});