Stripe Billing is the SaaS subscriptions standard in 2026. But for B2B SaaS with usage-based pricing, multi-currency Africa, or complex adjustments, you need a custom layer on top. Here's how to architect it cleanly.
TL;DR
- Stripe Billing core: Products + Prices + Subscriptions + Invoices.
- Custom layer: usage-metering, multi-currency, custom dunning, adjustments.
- Wave SN integration via webhooks for pan-AF SaaS.
Stripe Billing + custom architecture
`
[Customer subscribes]
↓
[Stripe Checkout / PaymentMethod]
↓
[Stripe Subscription created (with trial)]
↓
[Webhook subscription.created]
↓
[App creates local Subscription record]
↓
[Usage tracking → Stripe usage records]
↓
[Invoice cycle (monthly)]
↓
[Webhook invoice.payment_succeeded → activate features]
[OR invoice.payment_failed → dunning]
`
Step 1 — data model
`prisma
model Plan {
id String @id @default(cuid())
slug String @unique
name String
description String
stripePriceIdMonthly String
stripePriceIdYearly String?
basePriceXof Int
basePriceEur Int
basePriceUsd Int
currency String @default("EUR")
features Json
metering Json?
}
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
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
quantity Int
recordedAt DateTime @default(now())
stripeRecordId String?
}
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
paidAt DateTime?
hostedUrl String
pdfUrl String?
}
`
Step 2 — create subscription
`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 } });
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 },
});
}
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 });
}
`
Step 3 — subscription webhooks
`ts
export async function POST(req: NextRequest) {
const body = await req.text();
Need a professional website?
Kolonell builds websites that attract clients, optimized for the Sénégalese market. Free quote in 2 minutes.
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':
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,
},
});
}
`
Step 4 — usage-based metering
For usage features (AI credits, API calls, storage):
`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 },
});
await prisma.usageRecord.create({
data: { subscriptionId: subscription.id, meterType, quantity },
});
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),
},
});
}
await recordUsage(orgId, 'AI_CREDITS', 50);
`
Stripe aggregates usage and bills automatically at period end.
Step 5 — Wave SN multi-currency
For SN customers paying in XOF, Stripe doesn't take Wave. Workaround:
`ts
async function billSnCustomer(subscription) {
const amount = subscription.plan.basePriceXof;
const invoice = await prisma.invoice.create({
data: {
subscriptionId: subscription.id,
amount,
currency: 'XOF',
status: 'OPEN',
hostedUrl: ${APP_URL}/invoices/${invoice.id},
},
});
const paymentLink = await createWavePaymentLink({
amount,
reference: invoice.id,
callbackUrl: ${APP_URL}/api/webhooks/wave-billing,
});
await sendInvoiceEmail(subscription.organization, invoice, paymentLink);
}
`
Step 6 — dunning (failed payment management)
`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) {
await sendDunningEmail(subscription, 'first_failure');
} else if (failureCount === 3) {
await sendDunningEmail(subscription, 'urgent');
await sendWhatsApp(subscription.organization.phone, 'dunning_urgent');
} else if (failureCount >= 5) {
await downgradeToFreePlan(subscription);
}
}
`
Real case — Dakar B2B SaaS (450 clients)
| Metric | Value |
|---|---|
| MRR | 14M XOF |
| Monthly churn | 4.2% |
| Failed payments | 8% (before dunning) → 2% (after) |
| Trial → paid conversion | 22% |
| Average contract value | 31K XOF/month |
FAQ
Q: Stripe Billing vs Paddle?
A: Stripe for control. Paddle = MoR (auto handles VAT/sales tax) but 5-10% fees. For Africa: Stripe.
Q: Free trial mandatory?
A: Not mandatory but 7-14 day recommended. Boosts conversion 30-50%.
Q: Freemium vs trial pricing?
A: Trial = 14 days, freemium = forever free limited. Right choice depends on product. Productivity SaaS = freemium often better.
Conclusion
Stripe Billing custom layer = 2026 sweet spot for serious B2B SaaS. 2-4 weeks setup + ongoing maintenance. ROI: pricing agility + controlled churn + robust multi-currency.
Mohamed Bah
Fondateur, Kolonell
Passionate about digital and entrepreneurship in Africa, Mohamed has been helping Sénégalese businesses with their digital transformation since 2020. Founder of Kolonell, he believes every SME deserves a professional and accessible online présence.