Aller au contenu

Facturation récurrente avec Laravel

Des abonnements construits avec des paiements ordinaires dans Laravel, avec renouvellements et relances.

Wajub ne possède ni endpoint d'abonnement ni mandat enregistré. Vous construisez vous-même l'abonnement. Cette recette le fait dans Laravel avec trois migrations, une action, un contrôleur de webhook et trois commandes planifiées.

L'architecture et le raisonnement de chaque décision se trouvent dans Facturation récurrente. Cette page en fournit l'implémentation.

La solution avec les factures et son état actuel

Les factures possèdent une option is_recurring qui semble offrir un chemin plus court. Lisez d'abord Factures récurrentes : l'option est enregistrée et le cycle configuré, mais la tâche qui copie une facture dans le cycle suivant ne possède actuellement aucun premier lien. Aucune deuxième facture n'est donc générée. L'architecture qui suit est celle qui fonctionne.

La contrainte qui détermine le code

Chaque débit Mobile Money est confirmé par le client sur son téléphone avec son code PIN. Un renouvellement prend donc la forme d'une invite sur un téléphone, pas d'un débit silencieux. Il échoue si la personne dort ou manque de fonds.

Cette recette utilise donc trois commandes plutôt qu'une. Le rappel précède le débit et les relances viennent après.

1. Installer et configurer

npm install wajub/wajub-php

Ajoutez les identifiants à config/services.php afin qu'aucun appel à env() n'ait lieu hors d'un fichier de configuration. php artisan config:cache ne pourra ainsi pas les vider en production.

config/services.php
'wajub' => [
  'key' => env('WAJUB_API_KEY'),
  'webhook_secret' => env('WAJUB_WEBHOOK_SECRET'),
],

Enregistrez une seule fois le client comme singleton.

app/Providers/AppServiceProvider.php
use Wajub\Wajub;

public function register(): void
{
    $this->app->singleton(Wajub::class, fn () => new Wajub([
      'api_key' => config('services.wajub.key'),
      'webhook_secret' => config('services.wajub.webhook_secret'),
    ]));
}

2. Le schéma

Un plan associe un prix à un rythme. Un abonnement associe un client à un plan. Une facture représente un cycle et enregistre chaque tentative de débit.

database/migrations/0001_01_01_000000_create_billing_tables.php
Schema::create('plans', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->decimal('amount', 12, 2);
    $table->char('currency', 3)->default('XAF');
    $table->enum('interval', ['weekly', 'monthly', 'yearly']);
    $table->timestamps();
});

Schema::create('subscriptions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->foreignId('plan_id')->constrained();
    $table->string('status')->default('incomplete');
    $table->string('phone', 32);
    $table->string('email')->nullable();
    $table->timestamp('current_period_start');
    $table->timestamp('current_period_end');
    $table->boolean('cancel_at_period_end')->default(false);
    $table->timestamp('cancelled_at')->nullable();
    $table->timestamps();
});

Schema::create('billing_invoices', function (Blueprint $table) {
    $table->id();
    $table->foreignId('subscription_id')->constrained();
    $table->string('kind');
    $table->decimal('amount', 12, 2);
    $table->char('currency', 3);
    $table->string('status')->default('open');
    $table->string('payment_id', 64)->nullable()->unique();
    $table->unsignedInteger('attempts')->default(0);
    $table->timestamp('period_end');
    $table->timestamp('due_at');
    $table->timestamp('paid_at')->nullable();
    $table->timestamps();
});

Un seul index remplace tout le code applicatif autrement nécessaire.

La protection contre le double débit
DB::statement(
  'CREATE UNIQUE INDEX billing_invoices_open_per_sub
     ON billing_invoices (subscription_id)
   WHERE status = \'open\'',
);

Un abonnement peut désormais posséder au maximum une facture ouverte. Si le planificateur s'exécute deux fois ou si deux workers traitent le même abonnement, la seconde insertion échoue et est ignorée. La base de données refuse l'opération au lieu d'une vérification préalable exposée aux accès simultanés.

3. Le seul endroit qui appelle l'API

Tous les débits passent par cette action : premier paiement, renouvellements et nouvelles tentatives de relance.

app/Actions/Billing/ChargeInvoice.php
namespace App\Actions\Billing;

use App\Models\BillingInvoice;
use Wajub\Objects\Payment;
use Wajub\RequestOptions;
use Wajub\Wajub;

class ChargeInvoice
{
    public function __construct(private readonly Wajub $wajub)
    {
    }

    public function handle(BillingInvoice $invoice): Payment
    {
        $subscription = $invoice->subscription;
        $plan = $subscription->plan;
        $attempt = $invoice->attempts + 1;

        $payment = $this->wajub->payments->create([
          'amount' => (float) $invoice->amount,
          'currency' => $invoice->currency,
          'customer' => [
            'phone' => $subscription->phone,
            'email' => $subscription->email,
          ],
          'description' => "{$plan->name}, ".$invoice->period_end->translatedFormat('F Y'),
          'reference' => "sub-{$subscription->id}-inv-{$invoice->id}",
          'callback' => route('billing.return', $subscription),
        ], new RequestOptions(
          idempotencyKey: "sub-{$subscription->id}-inv-{$invoice->id}-attempt-{$attempt}",
        ));

        $invoice->update([
          'payment_id' => $payment->id,
          'attempts' => $attempt,
        ]);

        return $payment;
    }
}

Deux détails sont essentiels.

La clé d'idempotence contient le numéro de tentative. Sans lui, une deuxième tentative renvoie le paiement échoué de la première au lieu d'en créer un nouveau. L'abonnement reste alors bloqué.

La valeur description est lue par le client dans l'invite sur son téléphone. Premium, octobre 2026 est confirmé. Un simple nom de marchand est refusé.

4. Créer l'abonnement

La création de l'abonnement écrit deux lignes, puis effectue le débit. Rien n'est actif avant la confirmation des fonds. L'abonnement commence donc avec le statut incomplete.

app/Actions/Billing/StartSubscription.php
public function handle(User $user, Plan $plan, string $phone): string
{
    $subscription = DB::transaction(function () use ($user, $plan, $phone) {
        $subscription = Subscription::create([
          'user_id' => $user->id,
          'plan_id' => $plan->id,
          'status' => 'incomplete',
          'phone' => $phone,
          'email' => $user->email,
          'current_period_start' => now(),
          'current_period_end' => $plan->addInterval(now()),
        ]);

        BillingInvoice::create([
          'subscription_id' => $subscription->id,
          'kind' => 'first',
          'amount' => $plan->amount,
          'currency' => $plan->currency,
          'period_end' => $subscription->current_period_end,
          'due_at' => now(),
        ]);

        return $subscription;
    });

    $payment = $this->chargeInvoice->handle($subscription->invoices()->first());

    return $payment->authorization_url;
}

Redirigez le client vers cette URL. Il choisit un opérateur, confirme sur son téléphone, puis votre webhook prend le relais.

5. Le contrôleur du webhook

C'est le seul endroit où un abonnement devient actif. La vérification exige le corps brut de la requête. Dans Laravel, utilisez $request->getContent(), jamais $request->all().

app/Http/Controllers/WajubWebhookController.php
namespace App\Http\Controllers;

use App\Jobs\ApplyPaidInvoice;
use App\Models\BillingInvoice;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Wajub\Exception\WebhookSignatureVerificationError;
use Wajub\Wajub;

class WajubWebhookController extends Controller
{
    public function __construct(private readonly Wajub $wajub)
    {
    }

    public function __invoke(Request $request): Response
    {
        try {
            $event = $this->wajub->webhooks->constructEvent(
                $request->getContent(),
                $request->header('X-Wajub-Signature', ''),
                $request->header('X-Wajub-Timestamp', ''),
            );
        } catch (WebhookSignatureVerificationError) {
            return response('', 400);
        }

        if ($event['event'] === 'payment.succeeded') {
            ApplyPaidInvoice::dispatch($event['data']['id']);
        }

        if (in_array($event['event'], ['payment.failed', 'payment.expired', 'payment.cancelled'], true)) {
            BillingInvoice::where('payment_id', $event['data']['id'])
              ->update(['status' => 'past_due']);
        }

        return response('', 200);
    }
}

Enregistrez la route hors du groupe web afin qu'aucun middleware CSRF ne s'y exécute.

routes/web.php
Route::post('/webhooks/wajub', WajubWebhookController::class)
  ->withoutMiddleware([VerifyCsrfToken::class]);

6. Appliquer un débit réussi

La tâche crée ses branches à partir de la valeur kind de la facture, jamais du statut actuel de l'abonnement. La même tâche peut s'exécuter deux fois lors d'une nouvelle tentative. Un statut déjà modifié n'est pas une entrée fiable.

app/Jobs/ApplyPaidInvoice.php
public function handle(Wajub $wajub): void
{
    $payment = $wajub->payments->retrieve($this->paymentId);

    if ($payment->status !== 'succeeded') {
        return;
    }

    $invoice = BillingInvoice::where('payment_id', $this->paymentId)->first();

    if ($invoice === null || $invoice->status === 'paid') {
        return;
    }

    $invoice->update(['status' => 'paid', 'paid_at' => now()]);

    $subscription = $invoice->subscription;
    $plan = $subscription->plan;

    if ($invoice->kind === 'first') {
        $subscription->update([
          'status' => 'active',
          'current_period_start' => now(),
          'current_period_end' => $plan->addInterval(now()),
        ]);

        Mail::to($subscription->email)->send(new SubscriptionStarted($subscription));

        return;
    }

    $subscription->update([
      'current_period_start' => $subscription->current_period_end,
      'current_period_end' => $plan->addInterval($subscription->current_period_end),
    ]);

    Mail::to($subscription->email)->send(new RenewalConfirmed($subscription, $invoice));
}

Un renouvellement prolonge l'abonnement à partir de current_period_end, pas de la date actuelle. Un client qui paie deux jours en retard conserve ainsi la même date de facturation au lieu de la décaler chaque mois.

La vérification $invoice->status === 'paid' garantit l'idempotence. Relancez le même événement autant de fois que nécessaire : la seconde exécution s'arrête ici.

7. Trois commandes

Chacune effectue une seule tâche. Le planificateur les exécute dans l'ordre chaque matin.

app/Console/Commands/SendRenewalReminders.php
public function handle(): int
{
    $due = Subscription::query()
      ->where('status', 'active')
      ->where('cancel_at_period_end', false)
      ->whereBetween('current_period_end', [now()->addDays(2), now()->addDays(3)])
      ->get();

    foreach ($due as $subscription) {
        Mail::to($subscription->email)
          ->send(new RenewalUpcoming($subscription, $subscription->plan));
    }

    return self::SUCCESS;
}

La commande de renouvellement ouvre la facture suivante et la débite. La détection de la violation d'unicité assure toute la sécurité : une exécution en double n'insère rien et passe à la suite.

app/Console/Commands/ProcessRenewals.php
public function handle(ChargeInvoice $chargeInvoice): int
{
    $due = Subscription::query()
      ->where('status', 'active')
      ->where('current_period_end', '<=', now()->endOfDay())
      ->get();

    foreach ($due as $subscription) {
        if ($subscription->cancel_at_period_end) {
            $subscription->update(['status' => 'cancelled']);

            continue;
        }

        $plan = $subscription->plan;

        try {
            $invoice = BillingInvoice::create([
              'subscription_id' => $subscription->id,
              'kind' => 'renewal',
              'amount' => $plan->amount,
              'currency' => $plan->currency,
              'period_end' => $plan->addInterval($subscription->current_period_end),
              'due_at' => $subscription->current_period_end,
            ]);
        } catch (UniqueConstraintViolationException) {
            continue;
        }

        try {
            $chargeInvoice->handle($invoice);
        } catch (WajubError $e) {
            $invoice->update(['status' => 'past_due']);
            Log::error('Renewal charge failed to start', [
              'invoice' => $invoice->id,
              'error' => $e->getMessage(),
            ]);
        }
    }

    return self::SUCCESS;
}

Les relances retentent une facture past_due les premier, troisième et cinquième jours, puis abandonnent le septième. Trois tentatives sur une semaine constituent une valeur par défaut raisonnable. Chaque tentative affiche une autre invite sur le téléphone. Un nombre supérieur agace simplement une personne dont le portefeuille est vide.

app/Console/Commands/ProcessDunning.php
public function handle(ChargeInvoice $chargeInvoice): int
{
    $retryDays = [1, 3, 5];
    $giveUpDay = 7;

    foreach (BillingInvoice::where('status', 'past_due')->get() as $invoice) {
        $age = (int) $invoice->due_at->diffInDays(now());
        $subscription = $invoice->subscription;

        if ($age >= $giveUpDay) {
            $subscription->update(['status' => 'unpaid']);
            $invoice->update(['status' => 'uncollectible']);
            Mail::to($subscription->email)->send(new SubscriptionSuspended($subscription));

            continue;
        }

        if (! in_array($age, $retryDays, true)) {
            continue;
        }

        if ($invoice->attempts > array_search($age, $retryDays, true) + 1) {
            continue;
        }

        $chargeInvoice->handle($invoice);
        Mail::to($subscription->email)->send(new RetryingCharge($subscription, $invoice));
    }

    return self::SUCCESS;
}

La vérification attempts empêche la commande d'envoyer deux invites lors de deux exécutions le même jour. Chaque nouvelle tentative repasse par ChargeInvoice. Elle reçoit donc une nouvelle clé d'idempotence et crée un véritable nouveau paiement.

routes/console.php
Schedule::command('billing:remind')->dailyAt('06:00');
Schedule::command('billing:renew')->dailyAt('06:30');
Schedule::command('billing:dun')->dailyAt('07:00');

8. Tester un cycle complet sans attendre un mois

La sandbox permet de parcourir toutes les branches en un après-midi. Les six derniers chiffres du numéro de téléphone déterminent l'action de l'opérateur.

TéléphoneCas testé
+237670000000L'inscription réussit et l'abonnement devient active
+237670000001Le renouvellement échoue pour fonds insuffisants et les relances commencent
+237670000002L'opérateur refuse, même parcours avec une raison différente

Pour atteindre le renouvellement sans attendre, reculez la période et exécutez manuellement la commande.

Forcer un renouvellement
php artisan tinker --execute="App\Models\Subscription::find(1)->update(['current_period_end' => now()->subDay()])"

php artisan billing:renew

Transférez ensuite le webhook vers votre machine afin que la facture soit réellement réglée.

Livraison locale du webhook
wajub listen --forward-to localhost:8000/webhooks/wajub

Que pensez-vous de ce contenu ?