Skip to content

Recurring SaaS ​

A SaaS application that bills monthly or yearly subscriptions to customers in Sierra Leone.

With Orange Money Sierra Leone, every debit is approved by the customer on their phone with their Orange Money PIN: there is no card to charge silently. The reliable pattern is therefore: at each due date, your application sends a payment request, the customer approves it, and the payment.success webhook extends their access.

Architecture ​

[Your SaaS scheduler] ── due date reached
        |
        |  POST /v1/payments  (amount of the plan, customer's +232 number)
        v
    [SOAMTAR] ── Orange Money request ──> [Customer's phone]  approves with PIN
        |
        |  webhook payment.success (metadata.subscription_id)
        v
[Your SaaS]  extend_period(user)

Models ​

php
Schema::create('plans', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('name');                       // 'Pro', 'Team'
    $table->decimal('price_monthly_sle', 15, 2);  // e.g. 250.00
    $table->json('features');
    $table->timestamps();
});

Schema::create('subscriptions', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('user_id');
    $table->foreignUuid('plan_id');
    $table->string('phone');                      // +232XXXXXXXX, Orange Money number
    $table->string('status');                     // active, past_due, cancelled
    $table->timestamp('current_period_end')->nullable();
    $table->timestamp('cancel_at')->nullable();
    $table->timestamps();
});

Billing a period ​

php
class BillSubscription
{
    public function __construct(private SoamtarClient $soamtar) {}

    public function __invoke(Subscription $sub): void
    {
        $period = $sub->current_period_end?->format('Y-m') ?? now()->format('Y-m');

        $this->soamtar->payments()->create([
            'merchant_ref' => "SUB-{$sub->id}-{$period}",
            'amount' => $sub->plan->price_monthly_sle,
            'currency' => 'SLE',
            'provider' => 'orange_money_sl',
            'phone' => $sub->phone,
            'description' => "{$sub->plan->name} plan - {$period}",
            'metadata' => ['subscription_id' => (string) $sub->id, 'period' => $period],
        ], idempotencyKey: "sub:{$sub->id}:{$period}");   // one request per period
    }
}
php
// routes/console.php: every morning, bill the subscriptions that are due
Schedule::call(function () {
    Subscription::where('status', 'active')
        ->whereNull('cancel_at')
        ->where('current_period_end', '<=', now()->addDay())
        ->each(fn (Subscription $sub) => app(BillSubscription::class)($sub));
})->dailyAt('08:00')->timezone('Africa/Freetown');

Webhook handler ​

php
public function handle(Request $request, WebhookVerifier $verifier)
{
    $event = $verifier->verify($request);

    $subId = $event['data']['metadata']['subscription_id'] ?? null;
    $sub = Subscription::find($subId);
    if (! $sub) return response('skip');

    match ($event['event']) {
        'payment.success' => $this->onPaid($sub),
        'payment.failed', 'payment.expired' => $this->onNotPaid($sub),
        default => null,
    };

    return response('ok');
}

private function onPaid(Subscription $sub): void
{
    $end = ($sub->current_period_end ?? now())->max(now())->addMonth();

    $sub->update(['status' => 'active', 'current_period_end' => $end]);
    $sub->user->extendAccess(until: $end);
}

private function onNotPaid(Subscription $sub): void
{
    $sub->update(['status' => 'past_due']);
    $sub->user->notify(new PaymentRequestMissedNotification());
}

Cancellation by the user ​

php
public function cancel(Subscription $sub, Request $request)
{
    if ($request->boolean('at_period_end', true)) {
        $sub->update(['cancel_at' => $sub->current_period_end]);   // no more billing
    } else {
        $sub->update(['status' => 'cancelled']);
        $sub->user->revokeAccessImmediately();
    }
}

Edge cases ​

CaseSolution
The customer does not approve the request in timepayment.expired → past_due; send a reminder and a new request (new merchant_ref, e.g. -retry1)
The customer changes planBill the new plan from the next period (manual proration if needed)
The customer changes their Orange Money numberUpdate subscriptions.phone before the next billing
Free trialSet current_period_end to the end of the trial: the first request goes out on that date
Lost webhookDaily cron calling GET /v1/payments/{id} (or POST /verify) on unanswered requests

Native subscriptions ​

SOAMTAR also exposes a native subscriptions API (POST /v1/subscriptions with a plan_id and a customer_id created in the dashboard). See the API reference and check with support how renewals are approved with Orange Money Sierra Leone before relying on it.

Cohort / churn ​

sql
-- Renewals vs cancellations per month
SELECT
    DATE_TRUNC('month', updated_at) AS month,
    COUNT(*) FILTER (WHERE status = 'active') AS renewals,
    COUNT(*) FILTER (WHERE status = 'cancelled') AS cancellations
FROM subscriptions
WHERE updated_at >= NOW() - INTERVAL '12 months'
GROUP BY month
ORDER BY month DESC;

SOAMTAR Payment API Documentation.