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
| Cas | Solution |
|---|---|
| Le client ne valide pas la demande à temps | payment.expired → past_due ; envoyer un rappel et une nouvelle demande (nouvelle merchant_ref, par ex. -retry1) |
| Le client change de plan | Facturer le nouveau plan à partir de la période suivante (proratisation manuelle si besoin) |
| Le client change de numéro Orange Money | Mettre à jour subscriptions.phone avant la prochaine facturation |
| Période d'essai | Fixer current_period_end à la fin de l'essai : la première demande part à cette date |
| Webhook perdu | Cron 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;