Implémenter un système d'abonnement avec Stripe pour un SAAS
En début d'année, j'avais utilisé Lemonsqueezy pour distribuer des templates payants de Bloggrify.
Dans ce cas précis, il s'agit d'un achat "one shot" avec l'obtention d'une clé de license qui permet d'utiliser ensuite le template.
La différence entre RssFeedPulse et les templates payants de Bloggrify, c'est qu'un SAAS propose bien souvent un système d'abonnement.
Un SAAS peut avoir plusieurs modèles : paiement à la consommation, paiement par tiers, paiement lié au nombre de users (ou par fonctionnalités) etc.
Il y a donc deux éléments importants :
- savoir mesurer l'usage
- pouvoir déclencher le paiement à échéance fixe
(Et évidemment toutes les petites subtilités comme les changements de plans de facturation en cours de mois, l'annulation, les relances etc.)
Plusieurs services vous proposent de gérer une partie de ces problèmes. Mais le plus connu reste Stripe
Stripe a une documentation incroyable, mais, les possibilités de Stripe étant assez importantes, c'est plutôt facile de s'y perdre.
Je vous propose donc ici de vous partager comment j'ai mis en place le mécanisme d'abonnement par tiers sur RssFeedPulse
Et, avant qu'on me fasse la remarque, oui, j'ai bien regardé la
qui est sorti il y a quelques semaines.Mais, il y a deux trois éléments dans son implémentation que je trouve un peu dommage :
- la construction de la table de pricing qui se fait avec des données en dur qu'il copie depuis Stripe. On peut simplifier ça.
- les liens de paiement, c'est bien pour du paiement en one shot, mais pas pratique du tout pour de l'abonnement. (je vous explique plus bas)
- l'obligation de demander à l'utilisateur de raffraichir la page pour voir son abonnement actif. (je vous explique, aussi, plus bas)
Vue d'ensemble de la stack
Ma stack est assez simple, je l'ai déjà abordé dans un précédent billet:
- un backend fait avec Spring Boot (Kotlin)
- un frontend fait avec Nuxt, déployé sur cloudflare
Le schéma général ressemble donc à cela :
Maintenant voici le workflow général :
- Un utilisateur va voir votre table des offres sur votre site.
- l'utilisateur va sélectionner une offre et souscrire depuis le site de Stripe.
- Stripe va envoyer l'information à votre application via un weebhook pour vous informer de la souscription
- Stripe va débiter la carte tous les mois et vous informer si l'offre change (souscription annulée, ou modifiée)
La table des offres dans Stripe
La table des offres, vous pourriez bien sûr la faire de votre côté sur votre site, puis l'implémenter à nouveau sur Stripe. C'est un peu dommage et vous risquez de faire des erreurs. Il n'y a rien de plus frustrant pour un utilisateur de voir un prix sur le site et de constater que le prix a changé au moment du paiement.
Mais déjà, voyons à quoi ressemble une table de pricing :
Vous noterez qu'on trouve :
- les prix mensuels et annuels
- la liste des seuils par plans tarifaires
- les features incluses dans chaque plan
Et toutes ces informations, vous pouvez les saisir sur Stripe.
Pour cela, vous allez sur votre tableau de bord Stripe dans "Product catalog".
Ici, j'ai par exemple 4 produits, avec 2 variantes (annuelle et mensuelle).
Un produit c'est par exemple : Advanced plan
- emails/mois : 20 000
- abonnés : 5 000
- campagnes : illimités
Et pour chaque produit, vous pouvez avoir des variantes de prix. Ici, j'ai fait des variantes avec des prélèvements mensuels ou annuels.
Je ne détaille pas la création du produit, c'est plutôt trivial. Par contre, petite astuce, vous pouvez utiliser la notion de metadata pour stocker la liste des features. C'est possible aussi de le faire dans "Marketing Feature list" mais les metadata sont plus facile à manipuler ensuite selon moi.
Créer votre table des offres
Une solution no code
La première façon de faire, c'est d'utiliser la solution nocode proposé par Stripe. Dans le product catalog, vous pouvez créer votre "pricing table".
L'intégration est ensuite très simple, il suffit d'insérer un script et un webcomponent sur la page
<template>
<stripe-pricing-table
pricing-table-id="PRICING_TABLE_ID"
publishable-key="PK_TEST_ID"
/>
</template>
<script setup lang="ts">
useHead({
script: [
{
src: 'https://js.stripe.com/v3/pricing-table.js',
async: true,
defer: true,
},
],
})
</script>
Ce qui donne :
C'est certainement la solution la plus simple, mais elle vient avec quelques limitations :
- La personnalisation de l'UI est très limitée. (Mais si c'était que ça, je m'en fiche)
- Vous ne pouvez avoir que 4 produits et 3 variantes de prix (pour mon cas, ça ne me dérangeait pas)
La principale contrainte, c'est que si l'utilisateur clique sur le lien de souscription sans avoir créé de compte au préalable, vous ne lui aurez pas créé de compte.
Donc le workflow va devoir être :
- l'utilisateur souscrit une offre
- vous traitez l'évènement par un webhook et vous créez l'utilisateur dans le même temps
- vous envoyez à l'utilisateur un lien magique pour se connecter, avec l'email utilisé lors du checkout.
C'est malgré tout, la pratique que je recommanderais dans la majorité du temps.
Cependant, ce n'est pas ce que j'ai fait, parce que je voulais forcer l'inscription avant la souscription.
Pour être franc, au début, je n'avais pas non plus compris comment marchait cette fonctionnalité, donc c'est aussi pour ça que je m'en suis passé ^^
Une solution par API
Une bonne nouvelle avec Stripe, si c'est possible d'avoir une information par le dashboard, alors c'est possible aussi par API.
Vous pouvez récupérer toutes les informations nécessaires à créer cette table de pricing avec l'API.
Voici l'appel en Kotlin:
enum class PaymentRecurringPeriod {
day, week, month, year
}
data class Offers(
val monthlyOffers: List<Offer>,
val yearlyOffers: List<Offer>
)
data class Offer(
val id: String,
val name: String,
val description: String?,
val marketingFeatures: List<String>,
val price: Double,
val currency: String,
val recurringPeriod: PaymentRecurringPeriod
)
@Service
class StripeProductService(
@Value("\${baseURL}")
private val baseUrl: String,
@Value("\${stripe.api-key}")
private val apiKey: String,
) {
init {
Stripe.apiKey = apiKey
}
fun loadOffersFromStripe(): Offers {
val productListParams = ProductListParams.builder()
.setActive(true)
.build()
val priceListParams = PriceListParams.builder()
.setActive(true)
.build()
val products = Product.list(productListParams).data
val prices = Price.list(priceListParams).data
val monthlyOffers = products.mapNotNull { product ->
val price = prices.find { price -> price.product == product.id && price.recurring?.interval == "month" }
?: return@mapNotNull null
convertPriceToOffer(price, product)
}.toList()
val yearlyOffers = products.mapNotNull { product ->
val price = prices.find { price -> price.product == product.id && price.recurring?.interval == "year" }
?: return@mapNotNull null
convertPriceToOffer(price, product)
}.toList()
val offers = Offers(monthlyOffers.sortedBy { it.price }, yearlyOffers.sortedBy { it.price })
return offers
}
private fun convertPriceToOffer(price: Price, product: Product): Offer {
return Offer(
id = price.id,
name = product.name,
description = product.description,
marketingFeatures = product.marketingFeatures.map { feature -> feature.name },
price = price.unitAmount.toDouble() / 100,
currency = price.currency,
recurringPeriod = PaymentRecurringPeriod.valueOf(price.recurring?.interval ?: "day")
)
}
Ce code récupère les informations depuis Stripe, les transforme dans un format plus simple. Et cette information peut être renvoyée par API au frontend.
C'est exactement ce que j'utilise pour RssFeedPulse.
Le gros avantage, c'est que sur la landing page, je propose donc uniquement le signup comme lien d'action sur la table de pricing.
Voyons maintenant comment envoyer l'utilisateur sur la page de paiement, et comment lui permettre de modifier son abonnement.
Le paiement
Dans la vidéo de Marc Louvion, Marc utilise des liens de paiements.
Un lien de paiement peut se créer sur le dashboard Stripe et Marc stocke ensuite en dur pour chaque produit et prix, le lien de paiement associé.
C'est déjà beaucoup de temps perdu et un risque d'erreur. Le copier-coller étant plus difficile qu'on ne croit :)
Second problème de sa méthode, c'est que si l'utilisateur décide de changer son email au moment du paiement, Stripe nous renverra ensuite un évènement concernant un client inconnu (puisque l'email chez Stripe, sera différent de l'email chez nous).
Enfin dernier problème, l'utilisateur peut avoir plusieurs souscriptions actives pour un même email.
Bref, on va éviter tout ça en utilisant les tunnels de checkout à la place.
- Un tunnel de checkout permet de créer une session de paiement, pour un utilisateur donné.
- On créé le tunnel à la volée, plus besoin de créer des tonnes de liens de paiement depuis le dashboard Stripe.
Regardons dans le détail.
Si on inspecte le lien de ma table de pricing, chaque lien renvoie vers :
https://rssfeedpulse.com/api/subscription/checkout?planId=price_ID
Côté API, voici ce que ca donne :
@PostMapping("/api/subscription/checkout")
fun createCheckoutSession(email: String, planId: String): String {
return "redirect:" + stripeProductService.createCheckoutSession(email, planId)
}
fun createCheckoutSession(email: String, priceId: String): String {
val account = accountRepository.findByEmail(email) ?: throw BadParameterException("Account with $email not found")
// create a session for this user, fixing the email address so that we force the user to use the same adresse on Stripe
val params = SessionCreateParams.builder()
.addLineItem(
SessionCreateParams.LineItem.builder()
.setPrice(priceId)
.setQuantity(1L)
.build()
)
.setAutomaticTax(SessionCreateParams.AutomaticTax.builder().setEnabled(true).build())
.setAllowPromotionCodes(true)
.setClientReferenceId(account.id)
.setCustomerEmail(email)
.setMode(SessionCreateParams.Mode.SUBSCRIPTION)
.setSuccessUrl("$baseUrl/settings")
.setCancelUrl("$baseUrl/settings")
.build()
val session = Session.create(params)
return session.url
}
L'utilisateur est ensuite redirigé vers Stripe et ne pourra pas changer son email.
On a donc fait aucune action dans le dashboard stripe et on garantit l'usage du même email partout !
Le changement ou l'annulation d'offre
Une fois inscrit, un utilisateur a besoin de pouvoir annuler son abonnement ou de l'upgrader si besoin.
Vous pourriez utiliser aussi une session de checkout, mais le plus indiqué ici, c'est de donner accès au client au "customer portal".
C'est une page permettant d'annuler ou de changer de plan.
Une fois loggué, l'utilisateur peut donc consulter son plan et cliquer sur "Manage"
Le bouton Manage a une url qui ressemble à ceci : https://www.rssfeedpulse.com/api/subscription/manage
Et l'api cette fois ressemble à :
@PostMapping("/api/subscription/manage")
fun createPortalSession(email: String): String {
return "redirect:" + stripeProductService.createPortalSession(email)
}
fun createPortalSession(email: String): String {
val account = accountRepository.findByEmail(email) ?: throw BadParameterException("Account with $email not found")
val params = com.stripe.param.billingportal.SessionCreateParams.builder()
.setCustomer(account.stripeCustomerId)
.setReturnUrl("$baseUrl/settings")
.build()
val session = com.stripe.model.billingportal.Session.create(params)
return session.url
}
Vous noterez une subtilité ici, je renseigne le customerId Stripe au moment de la création du portail. Ça permet d'authentifier directement l'utilisateur sur Stripe.
Mais si vous avez bien suivi jusqu'ici, vous pourriez vous demander d'où vient ce customerId ?
Eh bien, c'est parce que nous n'avons pas encore traité les Webhook de Stripe.
Les webhook de Stripe
Un webhook, c'est un appel d'API de Stripe, fait sur votre application pour vous notifier d'un changement sur Stripe.
C'est comme ça que Stripe vous informe qu'un plan a été souscrit, payé, annulé, ou modifié.
Vous devez donc avoir une API chez vous qui permette de gérer les appels venant de Stripe.
L'url du webhook se configure sur le dashboard Stripe dans la partie "Developer". Et en production, vous devrez lister les évènements qui vous intéressent.
Voici ceux qui nous intéressent :
- customer.subscription.created
- customer.subscription.deleted
- customer.subscription.updated
Voici à quoi ressemble le code :
@RestController
@RequestMapping("/api/stripe/webhook")
class StripeWebhook (
@Value("\${stripe.api-key}")
private val apiKey: String,
@Value("\${stripe.webhook-secret}")
private val webhookSecret: String,
private val accountSubscriptionService: AccountSubscriptionService
) {
val logger: Logger = LoggerFactory.getLogger(StripeWebhook::class.java)
init {
Stripe.apiKey = apiKey
}
@PostMapping
fun handleWebhook(
@RequestBody payload: String,
@RequestHeader("Stripe-Signature") sigHeader: String
): ResponseEntity<String> {
try {
// check signature
val event: Event = Webhook.constructEvent(
payload, sigHeader, webhookSecret
)
when (event.type) {
"customer.subscription.created" -> {
val subscription = event.dataObjectDeserializer.deserializeUnsafe() as Subscription
val customerId = subscription.customer
val priceId = subscription.items.data[0].price.id
val customer = com.stripe.model.Customer.retrieve(customerId)
val email = customer.email
accountSubscriptionService.updateAccountPlan(email, priceId, customerId)
logger.info("Subscription updated for account with email: $email and new plan: $priceId")
}
"customer.subscription.deleted" -> {
val subscription = event.dataObjectDeserializer.deserializeUnsafe() as Subscription
val customerId = subscription.customer
val customer = com.stripe.model.Customer.retrieve(customerId)
val email = customer.email
accountSubscriptionService.removeAccountPlan(email)
logger.info("Subscription deleted for account with email: $email")
}
"customer.subscription.updated" -> {
val subscription = event.dataObjectDeserializer.deserializeUnsafe() as Subscription
val customerId = subscription.customer
val priceId = subscription.items.data[0].price.id
val customer = com.stripe.model.Customer.retrieve(customerId)
val email = customer.email
accountSubscriptionService.updateAccountPlan(email, priceId, customerId)
logger.info("Subscription updated for account with email: $email and new plan: $priceId")
}
else -> {
logger.info("Unhandled event type: ${event.type}")
}
}
return ResponseEntity.ok("Success")
} catch (e: Exception) {
logger.error("Error: ${e.message}")
return ResponseEntity.badRequest().body("Error: ${e.message}")
}
}
}
Vous noterez que l'on récupère le customerId à la souscription pour l'associer à notre utilisateur chez nous (le code de updateAccountPlan n'est pas fourni, mais il est assez trivial) Ce customerId nous permet d'identifier de facon certaine l'utilisateur qui a effectué une opération sur Stripe.
Ok c'est cool mais, notre utilisateur ne voit pas son abonnement actif sur le site. Il doit raffraichir la page pour voir son abonnement actif.
Heureusement, non. On peut faire mieux.
Raffraichir la page automatiquement
De mon côté, j'utilise nuxt-auth-utils pour la gestion de la session côté Frontend. Cette session contient le nom du plan souscrit par l'utilisateur.
Problème, si l'utilisateur change de plan, la session n'est pas mise à jour.
Il va falloir ruser.
Pour cela, j'ai un poller qui va vérifier toutes les 10 secondes si le plan a changé.
const { session, fetch, clear, user } = useUserSession()
onMounted(() => {
const fetchSession = async () => {
try {
if (!user.value) return
const result = await $fetch<AccountDTO>('/api/auth/session')
if (result) {
if (user.value?.plan !== result.plan || user.value.blocked !== result.blocked) {
// This call is key.
// If the plan has changed, we fetch the session again
await fetch()
}
}
} catch(err: any) {
console.log('Request failed: ', err.message)
}
}
setInterval(fetchSession, 10000)
})
De cette façon, aucun besoin de demander à l'utilisateur de raffraichir la page. Il verra son abonnement actif en temps réel (ou presque).
Et non, je n'ai pas voulu m'embêter avec du websocket ou du SSE pour ça. C'est un peu overkill pour ce cas d'usage.
Le polling toutes les 10 secondes ça consomme des ressources. Mais, RssFeedPulse n'est pas un site sur lequel on reste connecté toute la journée. On paramètre sa newsletter et on se déconnecte, donc ça ne me dérange pas.
Conclusion
Avec ce billet, vous avez désormais de quoi bien comprendre :
- la création de votre table de pricing
- comment proposer un tunnel de paiement à vos utilisateurs
- comment permettre un changement de plan tarifaire à vos utilisateurs
- la réception des notifications de Stripe
Avec l'usage des APIs, vous n'aurez rien de hardcodé chez vous, hormis bien sûr les credentials de Stripe.