Aller au contenu

Abonnements

Offres, souscription, essai, renouvellement, prélèvement de la carte enregistrée, délai de grâce, changement d'offre, annulation et événements du module de facturation.

Un client souscrit à une offre, et le module facture chaque période par une facture à lui, numérotée, taxée et imprimée comme les autres. Cette page s'adresse au développeur qui vend un service à l'année ou au mois : elle décrit ce que fait le module de la souscription à la fin de l'abonnement, et ce qu'un autre module peut en écouter.

Activer

Les abonnements sont désactivés par défaut. Dans .env.local :

FRAZA_MODULE_BILLING_SUBSCRIPTIONS_ENABLED=true

Désactivés, rien ne paraît : ni les écrans Abonnements et Offres de l'admin, ni leurs routes d'API, ni les pages publiques, ni la section de l'espace client, ni la tâche billing-subscriptions. Les tables existent quand même : fraza-db init et la migration 008-subscriptions les créent dans tous les cas, pour qu'une activation ultérieure ne demande aucune migration.

Les quatre réglages du cycle (avis, grâce, nouvelles tentatives, durée du lien) sont dans Configuration.

Le principe

Facturation reste la seule source des factures. Les abonnements de Stripe (Stripe Billing) ne sont pas utilisés. Chaque période donne une facture du module, avec ses taxes figées à l'émission, son numéro, son PDF et sa page publique. Stripe ne fait qu'encaisser : il prélève la carte enregistrée quand l'abonnement est en prélèvement automatique, et le client paie sinon par carte sur la page de paiement de l'abonnement.

La raison : deux systèmes qui calculent chacun les taxes d'une même vente finissent par ne plus concorder, et la facture de Stripe ne porte ni la TVQ ventilée ni les numéros d'inscription du module.

Le client voit un abonnement, pas des factures. Les factures d'un abonnement existent pour la comptabilité, mais le client ne reçoit ni leur courriel ni leur page : il reçoit des avis de l'abonnement, qui joignent la facture ou le reçu en PDF, et il paie sur la page de l'abonnement. Un abonnement se paie par carte, donc par Stripe. Interac reste réservé aux factures faites à la main.

Payer une facture fait avancer l'abonnement, peu importe qui enregistre l'argent. Le webhook Stripe, un prélèvement de la carte, la réconciliation de nuit, un virement Interac saisi dans l'admin : tous aboutissent au recalcul de la facture dans le registre, qui émet invoice.paid dans la même transaction. L'abonnement écoute cet événement. Un chèque saisi à la main renouvelle donc l'abonnement exactement comme un prélèvement.

Le temps le fait avancer aussi, par la tâche quotidienne billing-subscriptions : émission du renouvellement, prélèvement, retard, fin.

Abonnements et récurrences restent deux choses distinctes. Une récurrence est un gabarit de facture que l'administrateur crée pour un client. Un abonnement a une offre, un statut, une carte, une période payée et une page où le client le gère.

Les offres

Une offre est ce qui se vend : un prix par intervalle, un profil de taxes, un essai facultatif et un mode de paiement. Elles se gèrent dans l'écran « Offres ».

ChampRôle
keyIdentifiant dans les URL, /za/billing/souscrire/<key>. Minuscules, chiffres et tirets, 63 caractères au plus. Fixée à la création : un lien déjà envoyé ou imprimé doit continuer de fonctionner.
name, descriptionAffichés au client. La description paraît sur la page de souscription.
price_centsPrix d'une période, avant taxes, pour un nouvel abonnement.
interval_unit, interval_valueday, week, month ou year, et le multiple, de 1 à 120.
tax_profileProfil de taxes de la ligne facturée. Voir Taxes.
trial_daysDurée de l'essai, de 0 à 365 jours. 0 facture dès la souscription.
payment_modeauto, manual ou choice. Voir plus bas.
group_keyGroupe d'offres exclusives. Facultatif.
is_activeUne offre inactive n'est plus proposée. Les abonnements en cours la gardent et continuent de se renouveler.
sort_orderOrdre d'affichage, du plus petit au plus grand.
metadataObjet JSON libre, que les pages publiques ne montrent pas. Un autre module y range ce qu'il associe à l'offre.

Une offre qui a déjà servi ne se supprime pas : la suppression répond 409 (plan_in_use), et l'offre se désactive à la place. Ses abonnements et leurs factures la nomment encore.

Le prix est figé par abonnement

Un abonnement copie le prix et l'intervalle de son offre le jour où il commence, et les garde tant qu'il se renouvelle sans interruption. Monter le prix d'une offre ne change rien à ce que paient les clients déjà abonnés. Seul un changement d'offre, ou une nouvelle souscription après la fin, applique le prix du jour.

Pour un prix de lancement ou un rabais, l'offre garde son prix régulier et une promotion le baisse : la facture montre alors le prix régulier, puis la ligne du rabais, et l'abonné garde sa promotion après qu'elle a pris fin.

Les groupes

Un client a au plus un abonnement en cours par offre, et par groupe quand l'offre en a un. Deux offres d'un même groupe s'excluent : « Développeur » et « Agence » dans le groupe pro, par exemple. Le client passe de l'une à l'autre par un changement d'offre, jamais par une seconde souscription (already_subscribed).

Le changement d'offre n'existe qu'à l'intérieur d'un groupe. Une offre sans groupe ne se change pas : l'abonnement s'annule, et le client souscrit à l'autre offre.

L'essai

Un essai est accordé une fois par client et par groupe. Un client qui a déjà eu un abonnement à cette offre ou à une offre de son groupe, même terminé, paie dès la souscription.

En libre-service, la règle s'étend à tous les clients qui portent le même courriel. Sans compte, chaque souscription crée une nouvelle fiche client (voir Souscription en libre-service) : sans cette extension, une seconde inscription sous la même adresse rouvrirait un essai.

L'administrateur qui abonne un client peut refuser l'essai, par l'interrupteur « Accorder l'essai » ou trial: false dans l'API.

Les modes de paiement

Mode de l'offreEffet
autoPrélèvement automatique : la carte enregistrée est prélevée à chaque renouvellement. Demande Stripe (STRIPE_SECRET_KEY et STRIPE_WEBHOOK_SECRET), sinon la souscription est refusée (stripe_disabled).
manualPaiement par lien : chaque renouvellement se paie par carte, depuis le lien de son avis, sur la page de paiement de l'abonnement. Aucune carte n'est enregistrée.
choiceAu choix du client, qui peut en changer ensuite. Le formulaire de souscription propose le prélèvement par défaut quand Stripe est configuré ; une souscription par l'API sans mode demandé part en paiement par lien.

Un abonnement tourne toujours sous auto ou manual, jamais sous choice. Le passage au prélèvement automatique demande une carte enregistrée : sans elle, la page du client ouvre la page Stripe d'enregistrement de carte, et c'est l'enregistrement qui fait le changement de mode.

Le cycle de vie

Les statuts

StatutLibelléCe qu'il signifie
trialingEn essaiCommencé sans facture ni carte, jusqu'à trial_end.
incompleteEn attente du paiementCréé sans essai, la première facture n'est pas encore payée.
activeActifLa période en cours est payée.
past_duePaiement en retardLe renouvellement n'est pas payé, le délai de grâce court. L'abonnement reste en cours.
endedTerminéFin définitive. Une nouvelle souscription repart de zéro, au prix du jour.

trialing, active et past_due sont les statuts sous lesquels un abonnement est en cours. L'annulation programmée n'est pas un statut : c'est cancel_at_period_end sur un abonnement qui reste actif jusqu'à la fin de la période payée.

La période payée est [current_period_start, current_period_end), fin exclue : le jour de current_period_end est celui du renouvellement, et la dernière journée couverte est la veille. Les pages l'affichent comme « Payé jusqu'au » cette veille.

Souscrire

Sans essai, l'abonnement naît incomplete, et sa première facture est émise tout de suite, due dans le délai habituel du client (FRAZA_MODULE_BILLING_DEFAULT_TERMS_DAYS, 30 jours par défaut). Son paiement rend l'abonnement actif, et la première période commence le jour du paiement, pas le jour de la souscription. La ligne de cette facture dit donc la durée, « à compter du paiement », plutôt que des dates.

Le client n'en reçoit pas le courriel. Souscrit en ligne, il part directement payer sur Stripe : seul « Abonnement actif », avec le reçu, lui parvient. S'il quitte Stripe sans payer, la tâche quotidienne lui envoie « Finalisez votre abonnement » le lendemain, une seule fois, puis les rappels avant échéance suivent. Un abonnement créé depuis l'admin envoie « Finalisez votre abonnement » tout de suite : personne n'est en train de payer.

Une première facture encore impayée le lendemain de son échéance termine l'abonnement (incomplete_expired), et elle est annulée par une note de crédit.

Une première période gratuite, par une promotion, donne une facture à 0 $ payée dès son émission : l'abonnement est actif aussitôt, sans courriel de facture, et le client reçoit « Abonnement actif ».

Avec un essai, l'abonnement naît trialing, sans facture et sans carte, et le client reçoit le courriel « Essai commencé ». Sept jours avant trial_end, la facture de la première période est émise, due ce jour-là, et le courriel « Fin de l'essai » part, avec la facture en PDF. Payée, même en avance, elle rend l'abonnement actif, et la première période suit l'essai sans trou : elle commence à trial_end. En prélèvement automatique avec une carte, la carte est prélevée le jour de trial_end, une seule fois.

Aucune grâce après un essai. Une facture de première période encore impayée le lendemain de trial_end termine l'abonnement (trial_unpaid), sans nouvelle tentative de prélèvement. La grâce sert aux clients qui ont déjà payé, pas à prolonger un essai.

Renouveler

FRAZA_MODULE_BILLING_SUBSCRIPTIONS_NOTICE_DAYS jours avant la fin de la période (30 par défaut), la facture de la période suivante est émise, due le jour du renouvellement, et l'avis « Renouvellement à venir » part avec elle en PDF. En prélèvement automatique, il nomme la carte qui sera prélevée et le jour du prélèvement, et ne demande rien. En paiement par lien, son bouton ouvre la page de paiement de l'abonnement. Une période gratuite donne une facture à 0 $, payée à son émission : l'abonnement est renouvelé d'avance, sans courriel.

Le jour du renouvellement :

  • en prélèvement automatique avec une carte, la carte est prélevée du solde de la facture ;
  • en paiement par lien, rien n'est prélevé : le client a reçu l'avis, et les rappels avant échéance lui ont été envoyés, sous forme d'avis de l'abonnement.

Payé, l'abonnement passe à la période suivante (subscription.renewed), et le client reçoit « Abonnement renouvelé » avec le reçu, à la place du « Paiement confirmé » d'une facture. Une carte refusée ce jour-là le fait passer past_due aussitôt, et le client reçoit « Renouvellement en attente ». En paiement par lien, le jour du renouvellement reste un jour pour payer : l'abonnement passe past_due le lendemain s'il est toujours impayé. Dans les deux cas, la grâce se compte depuis le jour du renouvellement.

Le retard et la grâce

Un abonnement past_due reste en cours pendant FRAZA_MODULE_BILLING_SUBSCRIPTIONS_GRACE_DAYS jours (30 par défaut), comptés depuis le jour du renouvellement.

  • En prélèvement automatique, la carte est retentée aux jours de FRAZA_MODULE_BILLING_SUBSCRIPTIONS_RETRY_DAYS (3,7,14 par défaut) après le renouvellement, une fois par jour au plus. Une tentative au-delà de la grâce n'a jamais lieu : la configuration écarte ces jours.
  • Sept jours avant la fin de la grâce, le client reçoit « Dernier rappel ».
  • GRACE_DAYS jours après le renouvellement, toujours sans paiement, l'abonnement se termine (unpaid) et sa facture impayée est annulée par une note de crédit.

Une facture payée pendant la grâce rend l'abonnement actif, et la nouvelle période commence au jour du renouvellement, pas au jour du paiement : le client ne gagne pas les jours de retard. Enregistrer une nouvelle carte pendant la grâce la prélève aussitôt.

Les factures d'un abonnement ne portent pas d'intérêts de retard, et ne reçoivent pas les avis de retard du module : ceux de l'abonnement les remplacent, pour que le client n'entende pas deux voix. La copie interne à FRAZA_MODULE_BILLING_ADMIN_EMAIL part quand même au premier retard.

La tâche quotidienne

billing-subscriptions tourne à 07 h 00 dans FRAZA_TIMEZONE, seulement quand les abonnements sont activés. Elle reprend chaque abonnement non terminé, un à la fois, et le fait avancer d'un pas tant qu'il y a quelque chose à faire, six pas au plus par abonnement et par passage.

StatutCe que la tâche fait, dans l'ordre
trialingAnnulé et trial_end atteint : fin. Sinon, à sept jours de trial_end : émet la facture de la première période et envoie « Fin de l'essai ». Le jour de trial_end, facture impayée : prélève la carte s'il y en a une, une seule tentative. Le lendemain, toujours impayée : fin (trial_unpaid).
incompletePremière facture impayée après son échéance, ou annulée : fin (incomplete_expired).
activeAnnulé et fin de période atteinte : fin (cancelled). Changement d'offre impayé après son échéance : abandonné. À NOTICE_DAYS de la fin : émet le renouvellement, avec l'offre programmée s'il y en a une. Le jour du renouvellement, impayé : prélève la carte ; refusée, passe past_due. Le lendemain, toujours impayé, passe past_due.
past_dueFin de grâce atteinte : fin (unpaid). Jour de nouvelle tentative : prélève la carte. À sept jours de la fin de grâce : « Dernier rappel ».

Chaque pas s'exécute dans sa propre transaction, l'abonnement verrouillé par SELECT … FOR UPDATE. Aucun appel à Stripe n'a lieu sous ce verrou : la décision de prélever est validée d'abord, le prélèvement se fait après, et son résultat revient par le registre. Les courriels des factures émises partent aussi après la validation. Un prélèvement refusé le jour du renouvellement fait donc passer l'abonnement past_due dans le même passage.

Les verrous se prennent toujours dans le même ordre, l'abonnement d'abord, la facture ensuite, y compris dans le registre : un paiement d'une facture d'abonnement verrouille son abonnement avant la facture. Un paiement et la tâche s'attendent au lieu de se bloquer mutuellement.

Exécutée deux fois le même jour, la tâche ne fait rien de plus : une facture se trouve déjà émise pour la période, un prélèvement déjà tenté ce jour-là, un avis déjà envoyé. Un index unique sur (subscription_id, period_start) empêche en dernier recours deux factures vivantes pour la même période. Voir Modèle de données.

Elle oublie aussi, à chaque passage, les liens de gestion et les sessions expirés depuis plus d'un jour.

Changer d'offre

Le changement se fait vers une autre offre active du même groupe, depuis la page du client ou depuis l'admin. Ce qui se passe dépend du prix.

Vers une offre plus chère, tout de suite. Une facture de changement facture la nouvelle offre pour une période complète à partir d'aujourd'hui, moins une ligne négative « Crédit pour la période non utilisée », calculée au prorata des jours qui restent de la période payée, à ce qu'elle a coûté, rabais d'une promotion compris. Son paiement change l'offre et le prix, et fait partir la nouvelle période du jour du changement. En prélèvement automatique, la carte est prélevée aussitôt.

Le crédit sur la facture évite une note de crédit partielle, que le module ne sait pas faire : une note annule toujours une facture entière.

Le crédit ne descend jamais la facture sous 0 $. Il est limité au prix de la nouvelle offre, rabais déduit : le module n'a pas de solde où reporter le reste. Une promotion qui rend la nouvelle offre gratuite, ou presque, fait donc perdre ce qui dépasse, et la révision du changement le dit avec le montant (credit_forfeited_cents dans l'aperçu). Une facture à 0 $ est payée à son émission, et le changement se fait aussitôt.

  • Une facture de renouvellement déjà émise et impayée est annulée d'abord : elle facturerait l'ancienne offre pour une période que le changement couvre.
  • Le changement est refusé si ce renouvellement est en partie payé (renewal_in_progress) ou payé (renewal_paid), si un autre changement attend son paiement (change_in_progress), ou si une annulation est programmée (cancel_scheduled).
  • Une facture de changement impayée après son échéance est abandonnée par la tâche, et l'abonnement continue avec son offre.

Vers une offre moins chère ou au même prix, au renouvellement. L'offre est programmée (pending_plan_id), sans remboursement. Le renouvellement suivant facture la nouvelle offre à son prix. Un renouvellement déjà émis et impayé est annulé, et la tâche le réémet à son passage suivant pour la nouvelle offre ; déjà payé, le changement attend une période de plus. Choisir à nouveau l'offre actuelle annule le changement programmé.

Une promotion suit le changement si elle vise l'offre d'arrivée, et s'arrête sinon. Une montée accepte aussi un code promo, qui remplace la promotion gardée s'il fait mieux qu'elle sur l'offre d'arrivée.

Avant de confirmer, le client voit la facture. « Continuer » ouvre la révision du changement (?changer=<offre>&abonnement=<id> dans l'adresse, que le bouton Précédent referme). À gauche, ce qui change : l'offre, la date d'effet, la nouvelle période, le prochain renouvellement et son montant, le mode de paiement. Viennent ensuite la promotion qui continue, ou celle qui s'arrête, et le crédit expliqué. À droite, la facture du jour ligne par ligne, avec les taxes et le total, le champ du code promo, et un bouton qui dit ce qu'il fait : « Payer 400,00 $ et passer à Agence » avec une carte enregistrée en prélèvement automatique, « Recevoir la facture de 400,00 $ » sinon, « Passer à Développeur le 26 octobre 2027 » pour une descente. Rien n'est facturé avant ce clic. Les chiffres viennent de previewPlanChange, calculés par le même brouillon que le changement : la facture émise est celle qui était affichée.

Pendant un essai, l'offre change simplement, avec son prix et son intervalle : rien n'a été payé, il n'y a rien à créditer. Une facture de première période déjà émise est annulée et réémise par la tâche pour la nouvelle offre.

Annuler

Par le client, un abonnement actif ou en essai s'arrête à la fin de la période payée : il garde ce qu'il a payé. La facture de renouvellement impayée, ou celle de la première période pendant un essai, est annulée, et le client reçoit « Annulation confirmée ». Jusqu'au dernier jour, il peut revenir sur sa décision (« Réactiver ») : la tâche réémet alors le renouvellement à son passage suivant. Le jour de current_period_end, la tâche termine l'abonnement (cancelled).

Un abonnement incomplete ou past_due n'a plus rien à garder : son annulation le termine tout de suite.

Par l'administrateur, « Terminer maintenant » termine l'abonnement aujourd'hui, quel que soit son statut (admin). Un remboursement, s'il est dû, est une étape à part, avec les outils existants : voir Remboursements.

À la fin, quelle qu'en soit la raison, les factures impayées de l'abonnement sont annulées par une note de crédit, subscription.ended est émis et le client reçoit « Abonnement terminé ». Une facture qui a déjà reçu une partie de son montant n'est pas annulée d'office : elle reste ouverte pour l'administrateur, et le journal du serveur le signale.

Deux portes pour le client

Le client gère ses abonnements depuis une page qui montre, pour chacun, une carte : l'offre et son statut, le prix net de la période en grand (le prix régulier barré sous une promotion), puis la promotion, la date payée, le prochain renouvellement ou la fin, et le mode de paiement avec la carte enregistrée (« Visa •••• 4242 »). Le motif d'un prélèvement refusé y paraît tant que le renouvellement est en retard, avec le lien « Payer ». Le pied de la carte porte les actions : enregistrer ou remplacer la carte, changer de mode de paiement quand l'offre le permet, et « Annuler l'abonnement » à droite, ou « Garder l'abonnement » une fois l'annulation programmée. Une seconde carte, « Changer d'offre », présente les autres offres du groupe en boutons radio, avec leur prix et leur description ; « Continuer » ouvre la révision du changement (voir Changer d'offre). L'annulation et le changement de mode de paiement demandent un second clic, au même endroit.

Sur la page du lien par courriel, les 50 dernières factures émises suivent, dans un tableau : numéro, date d'émission, montant, statut (« En retard » compris) et PDF. Dans l'espace client, elles ont leur propre section, « Factures ».

Deux chemins mènent à cette page, et tous deux se ramènent à une portée : les fiches client que le visiteur a le droit de voir. Un abonnement hors de la portée n'existe pas, la même réponse 404 qu'un abonnement inexistant.

Le lien par courriel, par défaut

Aucun compte n'est nécessaire. Le client saisit son courriel sur /za/billing/abonnements et reçoit un lien. Détenir la boîte de courriel est la preuve : c'est déjà là que partent ses factures.

  1. La réponse est la même, que le courriel soit celui d'un client ou non : la page ne dit à personne qui est client. Le lien n'est envoyé que s'il existe une fiche à cette adresse.
  2. Le lien vaut FRAZA_MODULE_BILLING_SUBSCRIPTIONS_LINK_MINUTES minutes (60 par défaut), une seule fois.
  3. Il ouvre /za/billing/abonnements/acces/<jeton>, qui n'affiche qu'un bouton. Le jeton n'est dépensé que par le clic, une requête POST, jamais par l'ouverture de la page : les antivirus de courriel qui suivent chaque lien d'un message pour le vérifier le dépenseraient avant que le client le voie.
  4. Le clic ouvre une session de 12 heures sur cet appareil, dans le cookie fraza_billing_access (HttpOnly, SameSite=Lax, Path=/za, Secure quand le site est en HTTPS). Elle donne accès aux abonnements de toutes les fiches client qui portent ce courriel, sans distinction de casse. « Fermer la session » la supprime.

La base ne garde que le SHA-256 des jetons et des sessions : une copie de la base n'ouvre la page de personne.

Chaque avis d'abonnement porte son propre lien vers cette page, valable 7 jours. Le client gère son abonnement depuis le courriel reçu, sans demander de lien d'abord. Ce lien reste à usage unique.

La demande de lien est limitée à 5 essais par 15 minutes, par adresse IP et par courriel, pour qu'une boîte ne soit pas inondée depuis plusieurs adresses. L'ouverture du lien partage le budget par adresse IP.

L'espace client du core, en option

Un site qui a des comptes clients monte l'espace client du core. Le module y ajoute deux sections, enregistrées depuis sa sous-entrée ./account :

SectionChemin par défautRangIcôneContenu
Abonnements/compte/abonnements20CreditCardIconLa même page que celle du lien, sans les factures. Sa carte d'accueil (Summary) résume les abonnements.
Factures/compte/factures25InvoiceIconLes 50 dernières factures émises de la fiche, en tableau. Visible seulement quand la fiche en a une.

La carte de l'accueil. Sur l'accueil de l'espace client, la carte « Abonnement » montre pour chaque abonnement en cours l'offre, son statut, le prix net (le prix régulier barré sous une promotion) et sa période, puis « Renouvellement le … », ou « Fin le … » quand l'annulation est programmée. Un renouvellement en retard ou une facture en attente y ajoute une ligne d'alerte avec le lien « Payer ». Le lien « Gérer » mène à la section. Sans abonnement en cours, elle montre le dernier terminé ; sans aucun abonnement, elle ne rend rien, et l'accueil vide reste au projet.

Les sections n'apparaissent que si le projet a monté la route de l'espace et que fraza-modules sync a régénéré app/.fraza/account.server.js. Le module n'en dépend pas : sans espace client, le lien par courriel suffit.

Un compte, une fiche client. Un index unique sur fraza_billing_client.user_id l'impose, et la section montre les abonnements de la seule fiche liée au compte. Une fiche se lie à un compte à la première souscription faite connecté, par linkClientToUser() de ./server, ou par le champ user_id de l'API d'admin. Un compte déjà lié à une autre fiche est refusé (409).

Un compte sans fiche voit la liste des offres actives, chacune vers sa page de souscription. Après l'enregistrement d'une carte, Stripe ramène le client à la section, construite par accountHref('abonnements'), qui suit FRAZA_ACCOUNT_PATH.

Pour envoyer un client vers sa section depuis une page du projet, accountSubscriptionsHref() de ./server rend ce chemin, ou null sur un core sans espace client. Une page du projet qui compose son propre tableau de bord lit les mêmes données par getAccountOverview(session) de ./server, voir API. Un visiteur non connecté est renvoyé à la connexion, qui le ramène à la section : voir Envoyer le client vers son espace.

La page de paiement

/za/billing/abonnements/<jeton> est la page où le client paie son abonnement : la première période, un renouvellement, une montée d'offre. Le jeton est celui de la facture qui la porte, la seule preuve demandée, comme pour la page d'une facture. Tous les boutons « Payer mon abonnement » des avis y mènent, et la page publique d'une facture d'abonnement, /za/billing/<jeton>, y redirige. Les documents restent où ils sont : /za/billing/<jeton>/pdf et /recu.

La page tient dans une boîte, comme un panier : l'offre, le prix par période, la promotion, la période couverte quand elle est connue, les lignes de la facture, les taxes et le total, puis un seul bouton, « Payer 286,29 $ par carte », qui ouvre Stripe. Stripe y ramène le client, avec ?paiement=succes ou ?paiement=annule. Payée, la même page dit « Abonnement actif », « Abonnement renouvelé » ou « Offre modifiée », et offre le reçu.

Le webhook de Stripe arrive parfois quelques secondes après le retour du client. La page affiche alors « Paiement en cours de confirmation » et demande l'état du paiement toutes les deux secondes et demie, puis se recharge une fois l'abonnement à jour.

Le lien « Gérer mon abonnement » mène à la section de l'espace client quand le visiteur est connecté et que le site en a un, et à /za/billing/abonnements sinon.

Pas d'Interac sur cette page. La page publique d'une facture d'abonnement ne la propose plus non plus, et l'API publique de la facture ne rend pas d'identifiants Interac pour elle. Un paiement reçu autrement, un chèque saisi dans l'admin par exemple, fait avancer l'abonnement comme avant.

Souscription en libre-service

Chaque offre active a sa page publique, /za/billing/souscrire/<key>. Le site y mène depuis sa page de tarifs, par un simple lien. Une offre inactive ou inconnue répond 404. Sans Stripe configuré, la souscription en ligne est fermée : la page le dit, et l'API répond stripe_disabled.

La page affiche le prix, l'essai s'il y en a un, la description, puis un formulaire : entreprise, ou prénom et nom, courriel, adresse, ville, code postal, et le mode de paiement quand l'offre laisse le choix et que Stripe est configuré.

Connecté, le visiteur souscrit comme la fiche liée à son compte. Sans fiche liée, il prend la plus ancienne fiche active de son courriel qui n'est liée à aucun compte, celle que l'administration a pu créer : le core vérifie le courriel d'un compte avant la connexion, c'est la preuve qui manque à une souscription sans compte. Sinon une fiche est créée et liée, avec le courriel du compte, qui n'est pas redemandé. Un compte qui a déjà sa fiche ne remplit aucun champ.

Sans compte, une nouvelle fiche client est toujours créée. Rien ne prouve que le visiteur détient le courriel qu'il saisit : il n'est donc jamais rattaché à une fiche existante. Chaque avis, et le reçu qu'il porte, partent à ce courriel, et c'est là que se trouve désormais le contrôle de l'abonnement.

Deux règles se lisent sur toutes les fiches du même courriel, pour qu'une seconde inscription sous la même adresse ne gagne rien : un abonnement en cours par groupe (already_subscribed_email, 409), et un essai par groupe.

Ce qui suit la souscription, le serveur le décide :

SituationSuite
EssaiUn message confirme l'essai. Le courriel « Essai commencé » porte le lien de gestion.
Prélèvement automatiqueRedirection vers Stripe, qui paie la première période et enregistre la carte, puis ramène à la page de paiement de l'abonnement.
Paiement par lienRedirection vers Stripe, qui paie la première période sans enregistrer la carte, puis ramène à la page de paiement de l'abonnement.
Stripe n'a pas pu s'ouvrirRedirection vers la page de paiement de l'abonnement, qui propose de nouveau la carte.

La réponse de l'API porte checkoutUrl, la page de Stripe, et paymentUrl, la page de paiement de l'abonnement. Un site qui a sa propre page de commande redirige vers le premier, à défaut le second.

Une page de commande propre au site

Un site qui vend depuis ses propres pages poste sur POST /za/api/billing/subscribe avec returnPath, et au besoin cancelPath : deux chemins du site, comme /compte/licences. Stripe ramène alors le client sur ces pages plutôt que sur la page de paiement de l'abonnement :

  • paiement réussi : returnPath?abonnement=<id>&paiement=succes ;
  • paiement abandonné : cancelPath?abonnement=<id>&paiement=annule, ou returnPath sans cancelPath.

Seul un chemin de ce site est retenu, vérifié par safeNextPath() du core : un / en tête, jamais //, ni barre oblique inverse, ni caractère de contrôle. Un chemin refusé est ignoré sans erreur, et Stripe ramène à la page de paiement de l'abonnement, comme sans returnPath. Une première période gratuite dont la carte s'enregistre revient aussi à returnPath, avec ?abonnement=<id>&carte=enregistree.

const response = await fetch('/za/api/billing/subscribe', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ plan: 'developpeur', returnPath: '/compte/licences', cancelPath: '/tarifs' }),
});
const { checkoutUrl, paymentUrl } = await response.json();
window.location.assign(checkoutUrl ?? paymentUrl);

Au retour, le webhook de Stripe peut arriver quelques secondes après le client. La page lit abonnement dans son URL et interroge GET /za/api/billing/account/subscriptions/<id>/status jusqu'à ce que status quitte incomplete et que payment_pending soit faux. Cette route demande un compte connecté et ne répond que pour les abonnements de sa fiche. Voir API.

Un retour sans paiement, puis un nouveau clic. Un client connecté qui revient de Stripe sans payer et souscrit de nouveau n'est ni refusé ni abonné deux fois. Sa souscription restée incomplete, première facture impayée, est reprise :

  • même offre, sans nouveau code promo : la même souscription et la même facture, avec une nouvelle page Stripe qui suit returnPath et cancelPath. Le mode de paiement suit le nouveau choix quand l'offre le laisse ;
  • autre offre du groupe, ou nouveau code promo : la souscription en attente prend fin sans courriel, sa facture est annulée par une note de crédit, et une nouvelle souscription est créée au prix du jour.

Une première facture déjà payée en partie n'est jamais abandonnée : la réponse reste already_subscribed (409). Sans compte, rien n'est repris, la règle d'un abonnement en cours par groupe s'applique (already_subscribed_email) : rien ne prouve que le visiteur détient le courriel saisi.

La réponse porte aussi subscriptionId et invoice, la première facture (numéro, montant, solde, échéance, lien de paiement), pour une page qui l'affiche elle-même. Il n'y a pas d'instructions Interac à afficher : un abonnement se paie par carte, voir La page de paiement.

Une souscription refusée supprime la fiche créée pour elle. La route est limitée à 10 essais par heure et par adresse IP.

Les écrans d'admin

Une section « Abonnements » s'ajoute à l'admin, juste sous Facturation, avec trois items conditionnés à billing.view : « Abonnements » (/admin/billing/subscriptions, icône CreditCardIcon), « Offres » (/admin/billing/plans, icône CrownIcon) et « Promotions » (/admin/billing/promotions, icône DiscountIcon).

Abonnements s'ouvre sur l'onglet « En cours », tout ce qui n'est pas terminé, avec cinq compteurs : en essai, actifs, paiement en retard, en attente du paiement, annulations programmées. Une annulation programmée paraît en indication sous le badge d'un abonnement qui reste actif.

La page d'un abonnement ne s'édite pas champ par champ : son prix et sa période viennent de son offre et de ses factures. Elle montre le résumé, les factures de l'abonnement avec leur rôle (début, renouvellement, changement d'offre), et l'historique de ses événements. Les actions :

ActionEffet
Annuler à la fin de la période, RéactiverComme le client.
Terminer maintenantFin immédiate, motif « Terminé par l'administration ».
Changer d'offreComme le client, dans le même groupe.
Mode de paiementPrélèvement automatique ou paiement par lien, si l'offre le permet.
Lien d'enregistrement de carteOuvre une page Stripe où le client enregistre sa carte. Le lien s'envoie au client, ou s'ouvre avec lui. Il revient ensuite sur /za/billing/abonnements.
Mettre à jour maintenantExécute pour cet abonnement seul ce que la tâche du jour ferait.
Prélever maintenantSur une facture impayée, prélève la carte enregistrée de son solde.

« Nouvel abonnement » abonne un client existant à une offre, avec le mode de paiement et l'essai. Le client reçoit aussitôt « Finalisez votre abonnement », avec la facture en PDF et le lien de la page de paiement. Le prélèvement automatique choisi ici demande ensuite une carte, à enregistrer par le lien de la page de l'abonnement.

Stripe

Le client Stripe et la carte

Une carte enregistrée appartient à un client Stripe (customer). Le module en crée un par fiche client la première fois qu'une carte doit être enregistrée, avec une clé d'idempotence tirée de la fiche, et garde son identifiant dans fraza_billing_client.stripe_customer_id. Le numéro de carte n'atteint jamais le module : Checkout le recueille, Stripe le garde. Le module ne conserve que l'identifiant Stripe de la carte, sa marque, ses quatre derniers chiffres et son expiration.

La carte s'enregistre de deux façons.

Au paiement d'une facture. La session Checkout d'une facture d'un abonnement en cours en prélèvement automatique est ouverte avec le client Stripe et payment_intent_data.setup_future_usage = 'off_session'. Un seul passage paie la facture et garde la carte. À la réception de checkout.session.completed, le module lit la carte chez Stripe, avant d'ouvrir sa transaction, puis l'attache à l'abonnement dans la même transaction que le paiement.

Par la page d'enregistrement. Une session Checkout en mode: 'setup', qui ne prélève rien, sert à remplacer une carte ou à en ajouter une. Elle dure 35 minutes et revient sur la page du client avec ?carte=enregistree ou ?carte=annulee. Son achèvement fait de la carte celle de l'abonnement, passe l'abonnement en prélèvement automatique sauf si l'offre impose le paiement par lien, et, sur un abonnement past_due, prélève tout de suite le renouvellement en attente.

Une carte enregistrée devient la carte par défaut de la fiche, et celle de tous ses abonnements en prélèvement automatique.

Le prélèvement hors session

Le prélèvement est un PaymentIntent créé avec confirm: true et off_session: true, carte seulement, pour le solde de la facture. Ses métadonnées portent module: 'billing', charge: 'off_session', la facture, l'abonnement et la tentative.

La clé d'idempotence est la facture, la tentative et le montant. Une tâche lancée deux fois, ou une instance tombée après la réponse de Stripe, retrouve le même PaymentIntent au lieu d'en créer un second.

Réponse de StripeSuite
RéussiEnregistré tout de suite dans le registre, ce qui renouvelle l'abonnement. « Abonnement renouvelé » part, avec le reçu.
Refusé, carte expirée, fonds insuffisantsLe motif est gardé sur l'abonnement (last_charge_error), et le client reçoit « Paiement refusé », une fois par tentative.
Authentification demandée (3D Secure)Traité comme un refus : la banque veut le titulaire, qui n'est pas là. Il paie depuis le lien de l'avis, sur la page de paiement.
En cours de traitementRien n'est écrit : le webhook dira la suite.
Aucune réponseRien n'est écrit : le webhook ou la réconciliation de nuit enregistrent le résultat.

Un index unique partiel sur stripe_payment_intent_id des paiements sans session Checkout rend le prélèvement, son webhook et la réconciliation inoffensifs l'un pour l'autre.

Les événements à cocher

Deux événements s'ajoutent à l'endpoint déclaré dans le tableau de bord Stripe :

ÉvénementEffet
payment_intent.succeededEnregistre un prélèvement dont la réponse n'est pas revenue.
payment_intent.payment_failedEnregistre un refus que le prélèvement n'a pas entendu.

Seuls les PaymentIntent dont metadata.charge vaut off_session et metadata.module vaut billing sont lus. Ceux de Checkout, et ceux d'un autre module du même compte Stripe, ne sont pas leur affaire : la session Checkout enregistre les premiers. checkout.session.completed, déjà coché, couvre aussi les sessions d'enregistrement de carte. La liste complète est dans Installation.

La réconciliation de nuit relit aussi les sessions d'enregistrement de carte et les prélèvements réussis des sept derniers jours.

Les événements pour les autres modules

Le module annonce ce qui arrive à ses factures et à ses abonnements. Un module de licences active une licence sur subscription.activated et la suspend sur subscription.ended, sans lire les tables de facturation.

import { registerBillingEventHandler } from '@fraza-pro/module-billing/server';

registerBillingEventHandler('subscription.activated', async (event, { client }) => {
  await client.query(
    `INSERT INTO mon_acces (subscription_id, billing_event_id, status)
     VALUES ($1, $2, 'active')
     ON CONFLICT (subscription_id) DO UPDATE SET status = 'active'`,
    [event.data.subscriptionId, event.id],
  );
}, { source: '@mon/module-acces' });

Le module Licences est bâti ainsi : voir Le statut suit l'abonnement.

L'appel se fait côté serveur, depuis le register() du module abonné. source est obligatoire : un gestionnaire par source et par type, et un second enregistrement remplace le premier au lieu de l'ajouter, ce qui rend un rechargement à chaud inoffensif. Un type inconnu lève à l'enregistrement. BILLING_EVENT_TYPES liste les types valides.

Un gestionnaire s'exécute dans la transaction qui a fait le changement, avec son client PostgreSQL, et les gestionnaires d'un même type s'exécutent l'un après l'autre. S'il lève, tout est annulé : le paiement d'un webhook Stripe est refusé par un 500, et Stripe le relivre ; une action de l'admin échoue ; un pas de la tâche est retenté le lendemain.

D'où la règle : un gestionnaire n'écrit qu'en base. Un courriel, un appel à un autre système, passe par une file ou par un état qu'une tâche rattrape. Un appel réseau dans la transaction tiendrait les verrous de la facture et de l'abonnement le temps de la réponse, et un échec externe annulerait un paiement reçu.

Chaque événement est d'abord une ligne de fraza_billing_event, écrite dans la même transaction. Son id est l'identifiant stable sur lequel l'abonné bâtit sa propre idempotence, et ces lignes sont l'historique de l'abonnement dans l'admin.

Le gestionnaire reçoit { id, type, occurredAt, data }. Les événements d'abonnement portent toujours subscriptionId, clientId, planId et status dans data, plus :

ÉvénementQuandChamps propres
invoice.issuedUne facture reçoit son numéro.invoiceId, clientId, subscriptionId, invoiceNumber, totalCents
invoice.paidLe statut d'une facture passe à paid, qui que soit l'auteur du paiement.invoiceId, clientId, subscriptionId, invoiceNumber, paidCents
invoice.cancelledUne facture est annulée.invoiceId, clientId, subscriptionId, invoiceNumber, creditNoteId
subscription.createdSouscription, en essai ou en attente du premier paiement.planKey, trialEnd, invoiceId, promotionId
subscription.activatedPremière facture payée, après une souscription ou un essai.invoiceId, periodStart, periodEnd
subscription.renewedFacture de renouvellement payée.invoiceId, periodStart, periodEnd
subscription.past_dueCarte refusée le jour du renouvellement, ou renouvellement impayé le lendemain : la grâce commence.invoiceId, graceEndsOn
subscription.plan_changedChangement d'offre.fromPlanId, toPlanId, immediate, invoiceId ou effectiveOn
subscription.cancel_scheduledAnnulation à la fin de la période.endsOn
subscription.resumedAnnulation reprise.—
subscription.endedFin, quelle qu'en soit la raison.reason

subscriptionId des événements de facture vaut null pour une facture hors abonnement. reason de subscription.ended vaut cancelled, unpaid, trial_unpaid, incomplete_expired ou admin.

subscription.plan_changed part deux fois pour une descente : à la programmation, avec immediate: false et effectiveOn, puis au paiement du renouvellement qui l'applique, avec immediate: false et invoiceId. C'est le second qui marque le changement effectif. Une montée part une fois, au paiement de sa facture, avec immediate: true.

Notes

  • L'abonnement ne décide pas de ce à quoi il donne accès. Un autre module le décide, par les événements ou par getSubscriptionById() et listClientsSubscriptions() de ./server : trialing, active et past_due sont les statuts d'un abonnement en cours.
  • Une offre programmée par une descente puis désactivée n'est pas appliquée : le renouvellement facture l'offre actuelle, et le changement programmé est abandonné.
  • already_subscribed_email dit au visiteur qu'un abonnement est en cours pour ce courriel. C'est la seule réponse du module qui le révèle : sans elle, une seconde souscription sous la même adresse passerait.
  • La facture d'un abonnement est une facture ordinaire pour l'administration : elle s'annule, se rembourse, et un paiement reçu autrement, un chèque par exemple, se saisit dans l'admin. Le client, lui, ne la paie que par carte. Annuler à la main un renouvellement, ou la facture de fin d'essai, laisse la période sans facture : la tâche en émet une autre à son passage suivant. Annuler la première facture d'un abonnement incomplete le termine au passage suivant.
  • Les dates des abonnements sont des jours dans FRAZA_TIMEZONE, comme celles des factures.

Sur cette page