Aller au contenu

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 attenduDéroulement réel
Wajub débite le client chaque moisVotre 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 penserIl 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.

Le schéma
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.

ÉtapeDonnées écrites
1Un abonnement incomplete, avec le téléphone du client et la période prévue
2Une facture avec kind = 'first', status = 'open' et une échéance aujourd'hui
3Rien 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.

Les clients à prévenir et ceux à débiter
-- 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.

ÉtapeActionProtection
1Si cancel_at_period_end est défini, marquer l'abonnement comme cancelled et passer au suivantAucun débit à effectuer
2Insérer la facture suivante avec kind = 'renewal' et status = 'open'L'index unique. Une insertion en double échoue et vous passez à la suite
3Appeler chargeInvoiceLe 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_atAction de la tâche de relance
1Nouvel appel à chargeInvoice, avec un message qui explique l'échec
3Nouvel appel à chargeInvoice, avec un avertissement sur la suspension prochaine de l'accès
5Dernier appel à chargeInvoice
7Abonnement 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.

TypeValeur définieCe que conserve le clientAction de la tâche de renouvellement
À la fin de la périodecancel_at_period_end = trueL'accès jusqu'à la fin de la période payéeAttribue le statut cancelled à la date d'échéance au lieu d'effectuer un débit
Immédiatementstatus = 'cancelled', factures ouvertes annuléesPlus aucun accès dès maintenantNe 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.

Que pensez-vous de ce contenu ?