Skip to content

SaaS récurrent ​

Une application SaaS qui facture des abonnements mensuels ou annuels à des clients en Sierra Leone.

Avec Orange Money Sierra Leone, chaque débit est validé par le client sur son téléphone, avec son code PIN Orange Money : il n'y a pas de carte à débiter en silence. Le schéma fiable est donc le suivant : à chaque échéance, votre application envoie une demande de paiement, le client la valide, et le webhook payment.success prolonge son accès.

Architecture ​

[Planificateur de votre SaaS] ── échéance atteinte
        |
        |  POST /v1/payments  (montant du plan, numéro +232 du client)
        v
    [SOAMTAR] ── demande Orange Money ──> [Téléphone du client]  valide avec son PIN
        |
        |  webhook payment.success (metadata.subscription_id)
        v
[Votre SaaS]  extend_period(user)

Modèles ​

php
Schema::create('plans', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('name');                       // 'Pro', 'Team'
    $table->decimal('price_monthly_sle', 15, 2);  // par ex. 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, numéro Orange Money
    $table->string('status');                     // active, past_due, cancelled
    $table->timestamp('current_period_end')->nullable();
    $table->timestamp('cancel_at')->nullable();
    $table->timestamps();
});

Facturer une période ​

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' => "Plan {$sub->plan->name} - {$period}",
            'metadata' => ['subscription_id' => (string) $sub->id, 'period' => $period],
        ], idempotencyKey: "sub:{$sub->id}:{$period}");   // une demande par période
    }
}
php
// routes/console.php : chaque matin, facturer les abonnements arrivés à échéance
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());
}

Annulation par l'utilisateur ​

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

Cas limites ​

CasSolution
Le client ne valide pas la demande à tempspayment.expired → past_due ; envoyer un rappel et une nouvelle demande (nouvelle merchant_ref, par ex. -retry1)
Le client change de planFacturer le nouveau plan à partir de la période suivante (proratisation manuelle si besoin)
Le client change de numéro Orange MoneyMettre à jour subscriptions.phone avant la prochaine facturation
Période d'essaiFixer current_period_end à la fin de l'essai : la première demande part à cette date
Webhook perduCron quotidien qui appelle GET/v1/payments/{id} (ou POST/verify) sur les demandes sans réponse

Abonnements natifs ​

SOAMTAR expose aussi une API d'abonnements natifs (POST/v1/subscriptions avec un plan_id et un customer_id créés dans le tableau de bord). Voir la référence de l'API, et vérifiez avec le support comment les renouvellements sont validés avec Orange Money Sierra Leone avant de vous appuyer dessus.

Cohorte / churn ​

sql
-- Renouvellements vs annulations par mois
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.