Implémenter un système d'abonnement avec Stripe pour un SAAS

By Hugo LassiègeMay 26, 202411 min read

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 :

stack RssFeedPulse

stack RssFeedPulse

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)

subscription workflow

subscription workflow

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 :

table de pricing

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).

product catalog

product catalog

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.

TIP

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 :

table de pricing en nocode

table de pricing en nocode

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.

TIP

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.

TIP

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"

Manage subscription

Manage subscription

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).

TIP

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.


Share this:

Written by Hugo Lassiège

Software engineer, ex-freelance, ex-cofounder, ex-CTO. I love building things, sharing knowledge and helping others.

Copyright © 2024
 Eventuallycoding
  Powered by Bloggrify