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
| Case | Solution |
|---|---|
| The customer does not approve the request in time | payment.expired → past_due; send a reminder and a new request (new merchant_ref, e.g. -retry1) |
| The customer changes plan | Bill the new plan from the next period (manual proration if needed) |
| The customer changes their Orange Money number | Update subscriptions.phone before the next billing |
| Free trial | Set current_period_end to the end of the trial: the first request goes out on that date |
| Lost webhook | Daily 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;