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étrique | Valeur |
|---|---|
| MRR | 14M FCFA |
| Churn mensuel | 4.2 % |
| Failed payments | 8 % (avant dunning) → 2 % (après) |
| Conversions trial → paid | 22 % |
| Average contract value | 31K 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.
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.