Skip to content

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 ​

CaseSolution
Webhook arrives before the customer is redirectedFine: 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 timesUnique event_key → processed once
Customer ignores the Orange Money request on their phoneSOAMTAR 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']);
});

SOAMTAR Payment API Documentation.