Facturation récurrente
Construisez des abonnements avec des paiements ordinaires : plans, cycles, renouvellements et relances.
Il n'existe aucun endpoint d'abonnement. Wajub ne possède ni plans, ni mandats enregistrés, ni débits exécutés selon son propre calendrier. Vous construisez un abonnement à partir de paiements ordinaires et d'une tâche exécutée chaque matin.
Le travail est moins important qu'il n'y paraît. Le système complet repose sur trois tables et trois tâches. Ce guide les présente toutes.
La contrainte qui détermine toute l'architecture
Chaque débit Mobile Money est confirmé par le client sur son téléphone avec son code PIN. Il n'existe aucune carte enregistrée à débiter silencieusement en arrière-plan ni aucun mandat à utiliser.
Un renouvellement n'est donc pas un débit silencieux. Une invite arrive sur le téléphone d'une personne. Si elle dort, participe à une réunion ou manque de fonds, le paiement échoue.
| Résultat attendu | Déroulement réel |
|---|---|
| Wajub débite le client chaque mois | Votre tâche crée un paiement, que le client confirme |
| Un renouvellement échoué est automatiquement relancé | Vous décidez du moment et du nombre de nouvelles tentatives |
| Le client n'a jamais à y penser | Il confirme chaque cycle et doit donc être prévenu |
Tout ce qui suit tient compte de cette contrainte. Le rappel n'est pas une amélioration facultative à ajouter plus tard. Un renouvellement inattendu par le client est un renouvellement qui échoue.
L'autre solution et son état actuel
Les factures possèdent une option is_recurring. Elle offre une solution plus légère pour un montant fixe à intervalle régulier. Lisez Factures récurrentes avant de la choisir : 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. En attendant sa mise en place, seule l'architecture de cette page fonctionne.
Un cycle complet
1. Trois tables
Un plan associe un prix à un rythme. Un abonnement associe un client à un plan. Une facture représente un cycle d'un abonnement et enregistre chaque tentative de débit.
La facture est la table la plus importante. Elle garantit l'idempotence d'un renouvellement, permet au webhook de retrouver son contexte et fournit les informations lues par votre comptable.
CREATE TABLE plans (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
currency CHAR(3) NOT NULL,
interval VARCHAR(16) NOT NULL,
trial_days INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
plan_id BIGINT NOT NULL REFERENCES plans (id),
status VARCHAR(16) NOT NULL DEFAULT 'incomplete',
phone VARCHAR(32) NOT NULL,
email VARCHAR(255),
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
cancelled_at TIMESTAMPTZ
);
CREATE TABLE invoices (
id BIGSERIAL PRIMARY KEY,
subscription_id BIGINT NOT NULL REFERENCES subscriptions (id),
kind VARCHAR(16) NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'open',
payment_id VARCHAR(64) UNIQUE,
attempts INTEGER NOT NULL DEFAULT 0,
period_end TIMESTAMPTZ NOT NULL,
due_at TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ
);
CREATE INDEX invoices_payment_id_idx ON invoices (payment_id);
CREATE UNIQUE INDEX invoices_open_per_sub ON invoices (subscription_id)
WHERE status = 'open';Le dernier index mérite votre attention. Il empêche un abonnement de posséder deux factures ouvertes simultanément. Une tâche de renouvellement exécutée deux fois par erreur ne peut donc pas débiter deux fois. La base de données refuse la seconde insertion sans dépendre d'une vérification dans votre code.
La colonne kind contient first ou renewal. Le webhook sait ainsi s'il doit activer ou prolonger un abonnement, sans le déduire d'un statut qu'il a peut-être déjà modifié.
2. L'inscription ouvre la première facture
L'abonnement effectue trois actions dans une seule transaction. Aucune ne concerne encore Wajub.
| Étape | Données écrites |
|---|---|
| 1 | Un abonnement incomplete, avec le téléphone du client et la période prévue |
| 2 | Une facture avec kind = 'first', status = 'open' et une échéance aujourd'hui |
| 3 | Rien d'autre. L'abonnement n'est pas actif et ne donne aucun accès |
Débitez ensuite cette facture et transmettez au client la valeur authorization_url reçue. Rien n'est actif avant la confirmation des fonds. C'est précisément pourquoi l'abonnement commence avec le statut incomplete.
3. Un débit utilisé par chaque cycle
C'est le seul endroit où l'API Wajub intervient dans tout le système. Le premier paiement, chaque renouvellement et chaque nouvelle tentative de relance passent tous par cette fonction.
curl https://api.wajub.com/payments \
-H "Authorization: sk.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
-H "Idempotency-Key: sub-84-inv-291-attempt-1" \
-H "Content-Type: application/json" \
-d '{
"amount": 9900,
"currency": "XAF",
"customer": { "phone": "+237670000000", "email": "amina@example.com" },
"description": "Premium, October 2026",
"reference": "sub-84-inv-291",
"callback": "https://app.example.com/billing/84/return"
}'Deux détails sont obligatoires.
La clé d'idempotence contient le numéro de tentative. Sans lui, une deuxième tentative sur la même facture renverrait le paiement échoué de la première au lieu d'en créer un nouveau. L'abonnement resterait alors définitivement bloqué.
La valeur description correspond au texte lu par le client dans l'invite sur son téléphone. Premium, October 2026 est confirmé. Un simple nom de marchand est refusé.
4. Le webhook décide en lisant la facture
Lorsqu'un paiement réussit, trouvez la facture grâce à payment_id et utilisez sa valeur kind pour déterminer l'action. Ne créez jamais de branche à partir du statut actuel de l'abonnement. Le même handler peut s'exécuter deux fois lors d'une nouvelle tentative. Un statut déjà modifié n'est pas une entrée fiable.
L'événement arrive sur l'endpoint déjà construit dans Accepter un paiement, guide complet. Seul le traitement après la vérification de signature change ici.
# The check the function makes. Everything else is your own bookkeeping.
curl https://api.wajub.com/payments/trx_CSUGajfv9xh0XQ5wu2lx \
-H "Authorization: sk.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"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 davantage chaque mois.
Un échec ne nécessite aucun appel. Trouvez la facture avec payment_id, attribuez-lui le statut past_due, enregistrez data.failure_reason, puis laissez les relances prendre le relais.
5. Prévenir avant le débit
Cette étape n'existe pas dans un système basé sur les cartes. Son absence constitue ici la principale cause d'échec des renouvellements.
Deux jours avant la fin de la période, envoyez un message qui indique le montant, la date et l'arrivée prochaine d'une invite. Le client recharge son portefeuille et répond à l'invite lorsqu'elle arrive.
-- Reminder job: warn these, two days out.
SELECT * FROM subscriptions
WHERE status = 'active'
AND cancel_at_period_end = false
AND current_period_end BETWEEN now() + interval '2 days'
AND now() + interval '3 days';
-- Renewal job: charge these, today.
SELECT * FROM subscriptions
WHERE status = 'active'
AND current_period_end <= date_trunc('day', now()) + interval '1 day';6. La tâche de renouvellement
Exécutez-la une fois par jour, tôt le matin. Pour chaque abonnement renvoyé par la seconde requête, elle effectue trois actions.
| Étape | Action | Protection |
|---|---|---|
| 1 | Si cancel_at_period_end est défini, marquer l'abonnement comme cancelled et passer au suivant | Aucun débit à effectuer |
| 2 | Insérer la facture suivante avec kind = 'renewal' et status = 'open' | L'index unique. Une insertion en double échoue et vous passez à la suite |
| 3 | Appeler chargeInvoice | Le numéro de tentative dans la clé d'idempotence |
La détection de la violation d'unicité à la deuxième étape constitue tout le mécanisme de sécurité. Si le planificateur s'exécute deux fois ou si deux workers traitent le même abonnement, la seconde insertion échoue. La boucle continue sans débiter deux fois le client. Laissez la base de données refuser l'opération au lieu d'effectuer une vérification préalable exposée aux accès simultanés.
Si chargeInvoice produit une exception, attribuez le statut past_due à la facture et enregistrez-la dans les logs. Le client n'a reçu aucune invite. Les relances reprendront donc la facture le lendemain comme tout autre échec.
7. Relancer après l'échec d'un renouvellement
Une facture past_due correspond à un client qui souhaite toujours le service, mais n'a pas pu payer ce matin. Accordez-lui quelques nouvelles tentatives espacées, puis arrêtez.
Jour après due_at | Action de la tâche de relance |
|---|---|
| 1 | Nouvel appel à chargeInvoice, avec un message qui explique l'échec |
| 3 | Nouvel appel à chargeInvoice, avec un avertissement sur la suspension prochaine de l'accès |
| 5 | Dernier appel à chargeInvoice |
| 7 | Abonnement unpaid, facture uncollectible, accès suspendu |
Trois tentatives sur une semaine constituent une valeur par défaut raisonnable. Un nombre supérieur agace les personnes dont le portefeuille est réellement vide. Chaque tentative affiche une invite sur leur téléphone.
Protégez la tâche avec la colonne attempts de la facture afin que deux exécutions le même jour n'envoient pas deux invites. Chaque nouvelle tentative repasse par chargeInvoice. Elle reçoit ainsi une nouvelle clé d'idempotence et crée un véritable nouveau paiement.
8. Annuler un abonnement
Il existe deux types d'annulation. Les clients peuvent désigner deux actions différentes par ce terme.
| Type | Valeur définie | Ce que conserve le client | Action de la tâche de renouvellement |
|---|---|---|---|
| À la fin de la période | cancel_at_period_end = true | L'accès jusqu'à la fin de la période payée | Attribue le statut cancelled à la date d'échéance au lieu d'effectuer un débit |
| Immédiatement | status = 'cancelled', factures ouvertes annulées | Plus aucun accès dès maintenant | Ne traite plus jamais l'abonnement |
L'annulation à la fin de la période doit être le comportement par défaut. Le client a payé le mois et conserve donc l'accès pendant tout le mois.
Aucune annulation n'est nécessaire du côté de Wajub, car aucun élément récurrent n'y a été enregistré. L'abonnement vous appartient entièrement. C'est le compromis présenté au début de cette page.
Chaque nouvelle tentative exige sa propre clé d'idempotence
Une facture, plusieurs tentatives et une clé différente à chaque fois. La réutilisation d'une clé renvoie le paiement précédent avec son ancien résultat. Un échec récupérable devient alors un abonnement impossible à réactiver. Le suffixe -attempt-{n} dans chargeInvoice assure tout ce mécanisme.
Pages associées
- Accepter un paiement, guide completLe cycle de vie d'une commande suivi par chaque débit de cette page.
- Factures récurrentesLa solution plus légère et son état d'avancement actuel.
- Facturation récurrente avec LaravelLe même problème, entièrement implémenté dans une application Laravel.
- IdempotenceLes clés qui assurent la déduplication et celles qui bloquent une nouvelle tentative.
- Checkout Mobile MoneyPourquoi un renouvellement prend la forme d'une invite sur un téléphone et ses conséquences.