Sites Web12 min de lecture

SaaS Stripe Billing : abonnements + métering custom (2026)

Mohamed Bah·Fondateur, Kolonell
20 mai 2026
Partager :
SaaS Stripe Billing : abonnements + métering custom (2026)

SaaS Stripe Billing : abonnements + métering custom (2026)

Sites Web

Stripe Billing est le standard SaaS subscriptions en 2026. Mais pour SaaS B2B avec pricing usage-based, multi-currency Africa, ou ajustements complexes, il faut une couche custom au-dessus. Voici comment l'architecturer proprement.

TL;DR

- Stripe Billing core : Products + Prices + Subscriptions + Invoices.

- Layer custom : usage-metering, multi-currency, dunning custom, ajustements.

- Wave SN intégration via webhooks pour SaaS pan-AF.

Architecture Stripe Billing + custom

`

[Customer s'abonne]

[Stripe Checkout / PaymentMethod]

[Stripe Subscription créée (avec trial)]

[Webhook subscription.created]

[App créé Subscription record local]

[Usage tracking → Stripe usage records]

[Invoice cycle (mensuel)]

[Webhook invoice.payment_succeeded → activate features]

[OU invoice.payment_failed → dunning]

`

Étape 1 — modèle de données

`prisma

model Plan {

id String @id @default(cuid())

slug String @unique // "starter", "pro", "enterprise"

name String

description String

stripePriceIdMonthly String // price_xxx

stripePriceIdYearly String?

basePriceXof Int // FCFA pour SN clients

basePriceEur Int // EUR pour clients UE

basePriceUsd Int // USD

currency String @default("EUR")

features Json // { ai_credits: 1000, users: 5, ... }

metering Json? // { aiCredits: { stripeMeterId, unitPriceCents } }

}

model Subscription {

id String @id @default(cuid())

organizationId String @unique

organization Organization @relation(fields: [organizationId], references: [id])

planId String

plan Plan @relation(fields: [planId], references: [id])

stripeSubscriptionId String @unique

stripeCustomerId String

status String // ACTIVE / TRIALING / PAST_DUE / CANCELED

currentPeriodStart DateTime

currentPeriodEnd DateTime

trialEnd DateTime?

cancelAtPeriodEnd Boolean

canceledAt DateTime?

invoices Invoice[]

usageRecords UsageRecord[]

}

model UsageRecord {

id String @id @default(cuid())

subscriptionId String

subscription Subscription @relation(fields: [subscriptionId], references: [id])

meterType String // AI_CREDITS / API_CALLS / STORAGE_GB

quantity Int

recordedAt DateTime @default(now())

stripeRecordId String? // si reporté à Stripe

}

model Invoice {

id String @id @default(cuid())

subscriptionId String

subscription Subscription @relation(fields: [subscriptionId], references: [id])

stripeInvoiceId String @unique

amount Int

currency String

status String // DRAFT / OPEN / PAID / VOID / UNCOLLECTIBLE

paidAt DateTime?

hostedUrl String

pdfUrl String?

}

`

Étape 2 — créer subscription

`ts

// app/api/billing/subscribe/route.ts

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET!);

export async function POST(req: NextRequest) {

const { planId, billingCycle } = await req.json();

const orgId = await getOrgId(req);

const org = await prisma.organization.findUnique({ where: { id: orgId } });

const plan = await prisma.plan.findUnique({ where: { id: planId } });

// 1. Stripe Customer (si pas déjà)

let stripeCustomerId = org.stripeCustomerId;

if (!stripeCustomerId) {

const customer = await stripe.customers.create({

email: org.email,

name: org.name,

metadata: { organizationId: org.id },

});

stripeCustomerId = customer.id;

await prisma.organization.update({

where: { id: org.id },

data: { stripeCustomerId },

});

}

// 2. Stripe Checkout Session

const session = await stripe.checkout.sessions.create({

mode: 'subscription',

customer: stripeCustomerId,

line_items: [{

price: billingCycle === 'yearly' ? plan.stripePriceIdYearly! : plan.stripePriceIdMonthly,

quantity: 1,

}],

success_url: ${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID},

cancel_url: ${process.env.APP_URL}/billing/canceled,

subscription_data: {

trial_period_days: 14,

metadata: { organizationId: org.id },

},

allow_promotion_codes: true,

});

return Response.json({ checkoutUrl: session.url });

}

`

Étape 3 — webhooks subscription

`ts

// app/api/webhooks/stripe-billing/route.ts

export async function POST(req: NextRequest) {

const body = await req.text();

const sig = req.headers.get('stripe-signature')!;

const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_BILLING_WEBHOOK!);

switch (event.type) {

case 'customer.subscription.created':

Besoin d'un site web professionnel ?

Kolonell crée des sites web qui attirent des clients, optimisés pour le marché sénégalais. Devis gratuit en 2 minutes.

case 'customer.subscription.updated':

await syncSubscription(event.data.object as Stripe.Subscription);

break;

case 'customer.subscription.deleted':

await markSubscriptionCanceled(event.data.object as Stripe.Subscription);

break;

case 'invoice.payment_succeeded':

await activateInvoice(event.data.object as Stripe.Invoice);

break;

case 'invoice.payment_failed':

await handlePaymentFailure(event.data.object as Stripe.Invoice);

break;

}

return Response.json({ received: true });

}

async function syncSubscription(stripeSub: Stripe.Subscription) {

const orgId = stripeSub.metadata.organizationId;

await prisma.subscription.upsert({

where: { stripeSubscriptionId: stripeSub.id },

create: {

organizationId: orgId,

planId: await getPlanIdFromStripePrice(stripeSub.items.data[0].price.id),

stripeSubscriptionId: stripeSub.id,

stripeCustomerId: stripeSub.customer as string,

status: stripeSub.status.toUpperCase(),

currentPeriodStart: new Date(stripeSub.current_period_start * 1000),

currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),

trialEnd: stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null,

cancelAtPeriodEnd: stripeSub.cancel_at_period_end,

},

update: {

status: stripeSub.status.toUpperCase(),

currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),

cancelAtPeriodEnd: stripeSub.cancel_at_period_end,

},

});

}

`

Étape 4 — usage-based metering

Pour features avec usage (AI credits, API calls, storage) :

`ts

// lib/billing/usage.ts

export async function recordUsage(

organizationId: string,

meterType: 'AI_CREDITS' | 'API_CALLS' | 'STORAGE_GB',

quantity: number

) {

const subscription = await prisma.subscription.findUnique({

where: { organizationId },

include: { plan: true },

});

// Stocker localement

await prisma.usageRecord.create({

data: { subscriptionId: subscription.id, meterType, quantity },

});

// Reporter à Stripe (Meter API 2024+)

const stripeMeterId = subscription.plan.metering[meterType.toLowerCase()].stripeMeterId;

await stripe.billing.meterEvents.create({

event_name: meterType.toLowerCase(),

payload: {

stripe_customer_id: subscription.stripeCustomerId,

value: String(quantity),

},

});

}

// Usage typique

await recordUsage(orgId, 'AI_CREDITS', 50); // user a consommé 50 crédits IA

`

Stripe agrège les usages et facture automatiquement à la fin de période.

Étape 5 — multi-currency Wave SN

Pour clients SN payant en XOF, Stripe ne prend pas Wave. Workaround :

`ts

// Plan SN : facturation locale via Wave Business B2B

async function billSnCustomer(subscription) {

const amount = subscription.plan.basePriceXof;

// Facture interne

const invoice = await prisma.invoice.create({

data: {

subscriptionId: subscription.id,

amount,

currency: 'XOF',

status: 'OPEN',

hostedUrl: ${APP_URL}/invoices/${invoice.id},

},

});

// Lien paiement Wave

const paymentLink = await createWavePaymentLink({

amount,

reference: invoice.id,

callbackUrl: ${APP_URL}/api/webhooks/wave-billing,

});

// Email client

await sendInvoiceEmail(subscription.organization, invoice, paymentLink);

}

`

Étape 6 — dunning (gestion impayés)

`ts

async function handlePaymentFailure(invoice: Stripe.Invoice) {

const subscription = await prisma.subscription.findUnique({

where: { stripeSubscriptionId: invoice.subscription as string },

include: { organization: true },

});

const failureCount = await getFailureCount(subscription.id);

if (failureCount === 1) {

// J0 : email "paiement échoué, retentez carte"

await sendDunningEmail(subscription, 'first_failure');

} else if (failureCount === 3) {

// J7 : escalation : email + WhatsApp

await sendDunningEmail(subscription, 'urgent');

await sendWhatsApp(subscription.organization.phone, 'dunning_urgent');

} else if (failureCount >= 5) {

// J21 : downgrade plan free + suspend access premium

await downgradeToFreePlan(subscription);

}

}

`

Cas réel — SaaS B2B Dakar (450 clients)

MétriqueValeur
MRR14M FCFA
Churn mensuel4.2 %
Failed payments8 % (avant dunning) → 2 % (après)
Conversions trial → paid22 %
Average contract value31K FCFA/mois

FAQ

Q : Stripe Billing vs Paddle ?

R : Stripe pour control. Paddle = MoR (gère VAT/sales tax automatiquement) mais 5-10 % de fees. Pour Africa : Stripe.

Q : Free trial obligatoire ?

R : Pas obligatoire mais recommandé 7-14 jours. Augmente conversion 30-50 %.

Q : Pricing freemium vs trial ?

R : Trial = 14 jours, freemium = forever free limited. Le bon choix dépend du produit. SaaS productivity = freemium souvent meilleur.

Conclusion

Stripe Billing custom layer = sweet spot 2026 pour SaaS B2B sérieux. 2-4 semaines setup + maintenance continue. ROI : agilité pricing + churn maîtrisé + multi-currency robuste.

Tags :#SaaS#Stripe Billing#Subscription#Usage Based#B2B#Architecture
Partager :

Mohamed Bah

Fondateur, Kolonell

Passionné par le digital et l'entrepreneuriat en Afrique, Mohamed accompagne les entreprises sénégalaises dans leur transformation digitale depuis 2020. Fondateur de Kolonell, il croit que chaque PME mérite une présence en ligne professionnelle et accessible.