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-phpAjoutez 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.
'wajub' => [
'key' => env('WAJUB_API_KEY'),
'webhook_secret' => env('WAJUB_WEBHOOK_SECRET'),
],Enregistrez une seule fois le client comme singleton.
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.
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.
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.
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.
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().
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.
Route::post('/webhooks/wajub', WajubWebhookController::class)
->withoutMiddleware([VerifyCsrfToken::class]);Après dix secondes, la livraison est considérée comme échouée
Une livraison sans réponse 2xx en dix secondes est tentée cinq nouvelles fois après trente secondes, une minute, cinq minutes, dix minutes et une heure. Comme ci-dessus, lancez une tâche et répondez immédiatement pour éviter qu'un système d'e-mail lent transforme un paiement en cinq livraisons.
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.
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.
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.
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.
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.
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éphone | Cas testé |
|---|---|
+237670000000 | L'inscription réussit et l'abonnement devient active |
+237670000001 | Le renouvellement échoue pour fonds insuffisants et les relances commencent |
+237670000002 | L'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.
php artisan tinker --execute="App\Models\Subscription::find(1)->update(['current_period_end' => now()->subDay()])"
php artisan billing:renewTransférez ensuite le webhook vers votre machine afin que la facture soit réellement réglée.
wajub listen --forward-to localhost:8000/webhooks/wajubPages associées
- Facturation récurrenteL'architecture mise en œuvre par cette recette et le rôle de chaque élément.
- Factures récurrentesL'autre solution et son état actuel précis.
- SDK PHPChaque ressource, option et exception.
- Handler de webhook avec ExpressLa déduplication et la mise en file d'attente, plus détaillées qu'à l'étape 5.
- IdempotencePourquoi la clé contient le numéro de tentative.