SaaS Billing Portal: Stripe Customer Portal, Plan Upgrades, Invoices, and Usage-Based Billing
Build a SaaS billing portal with Stripe: Customer Portal setup, plan upgrade/downgrade flows, proration handling, invoice history UI, usage-based billing with meters, and Terraform configuration.
Most SaaS products spend 3โ4 weeks building a billing portal from scratch โ plan comparison, upgrade flows, invoice downloads, payment method management โ only to realize Stripe's Customer Portal handles 80% of it out of the box. The remaining 20% (in-app upgrade prompts, usage metering, proration previews) requires custom code, but it integrates cleanly with the hosted portal.
This post covers the full billing stack: Customer Portal configuration, in-app plan upgrade flow with proration previews, usage-based billing with Stripe Meters, invoice history UI, and handling all the edge cases.
Stripe Customer Portal (The 80%)
The Stripe Customer Portal handles: payment method updates, subscription cancellation, plan switching, invoice downloads, and billing history โ with zero custom UI required.
// src/app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '../../../lib/stripe';
import { getServerSession } from 'next-auth';
import { db } from '../../../lib/db';
export async function POST(req: NextRequest) {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const account = await db.account.findUnique({
where: { userId: session.user.id },
select: { stripeCustomerId: true },
});
if (!account?.stripeCustomerId) {
return NextResponse.json({ error: 'No billing account found' }, { status: 404 });
}
const returnUrl = `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`;
const portalSession = await stripe.billingPortal.sessions.create({
customer: account.stripeCustomerId,
return_url: returnUrl,
// Optional: pre-select a flow
// flow_data: { type: 'payment_method_update' }
});
return NextResponse.json({ url: portalSession.url });
}
// Configure the portal (one-time setup, or via Stripe Dashboard)
// src/scripts/configure-stripe-portal.ts
import { stripe } from '../lib/stripe';
async function configurePortal() {
await stripe.billingPortal.configurations.create({
business_profile: {
headline: 'Viprasol โ Manage your subscription',
privacy_policy_url: 'https://viprasol.com/privacy',
terms_of_service_url: 'https://viprasol.com/terms',
},
features: {
invoice_history: { enabled: true },
payment_method_update: { enabled: true },
subscription_cancel: {
enabled: true,
mode: 'at_period_end', // No immediate cancellation
cancellation_reason: {
enabled: true,
options: ['too_expensive', 'missing_features', 'switched_service', 'other'],
},
},
subscription_update: {
enabled: true,
default_allowed_updates: ['price'],
proration_behavior: 'create_prorations',
products: [
{
product: process.env.STRIPE_PRODUCT_ID!,
prices: [
process.env.STRIPE_PRICE_STARTER!,
process.env.STRIPE_PRICE_PRO!,
process.env.STRIPE_PRICE_ENTERPRISE!,
],
},
],
},
},
});
}
1. In-App Plan Upgrade with Proration Preview
The Customer Portal handles plan switching, but you often want an in-app upgrade flow with a proration preview before the user commits.
// src/app/api/billing/upgrade-preview/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '../../../lib/stripe';
import { getServerSession } from 'next-auth';
import { db } from '../../../lib/db';
export async function GET(req: NextRequest) {
const session = await getServerSession();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const newPriceId = req.nextUrl.searchParams.get('priceId');
if (!newPriceId) return NextResponse.json({ error: 'Missing priceId' }, { status: 400 });
const sub = await db.subscription.findUnique({
where: { userId: session.user.id },
select: { stripeSubscriptionId: true, stripeSubscriptionItemId: true },
});
if (!sub?.stripeSubscriptionId) {
return NextResponse.json({ error: 'No active subscription' }, { status: 404 });
}
// Preview the invoice before charging
const preview = await stripe.invoices.retrieveUpcoming({
subscription: sub.stripeSubscriptionId,
subscription_items: [
{
id: sub.stripeSubscriptionItemId,
price: newPriceId,
},
],
subscription_proration_behavior: 'create_prorations',
});
// Format for UI
const prorationAmount = preview.lines.data
.filter((line) => line.proration)
.reduce((sum, line) => sum + line.amount, 0);
return NextResponse.json({
immediateChargeAmount: preview.amount_due / 100, // In dollars
prorationCredit: Math.abs(Math.min(prorationAmount, 0)) / 100,
prorationCharge: Math.max(prorationAmount, 0) / 100,
nextInvoiceAmount: preview.total / 100,
currency: preview.currency,
periodEnd: new Date(preview.period_end * 1000).toISOString(),
});
}
// src/app/api/billing/upgrade/route.ts โ execute the upgrade
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const session = await getServerSession();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { priceId } = await req.json();
const sub = await db.subscription.findUnique({
where: { userId: session.user.id },
});
if (!sub) return NextResponse.json({ error: 'No subscription' }, { status: 404 });
// Update the subscription immediately with proration
const updatedSub = await stripe.subscriptions.update(sub.stripeSubscriptionId, {
items: [{ id: sub.stripeSubscriptionItemId, price: priceId }],
proration_behavior: 'create_prorations',
payment_behavior: 'error_if_incomplete', // Fail if payment fails (don't silently downgrade)
expand: ['latest_invoice.payment_intent'],
});
// Update local DB (webhook will also update, but update immediately for UX)
const newPrice = await stripe.prices.retrieve(priceId);
await db.subscription.update({
where: { id: sub.id },
data: {
stripePriceId: priceId,
plan: getPlanFromPrice(priceId),
updatedAt: new Date(),
},
});
return NextResponse.json({
success: true,
plan: getPlanFromPrice(priceId),
currentPeriodEnd: new Date(updatedSub.current_period_end * 1000).toISOString(),
});
}
function getPlanFromPrice(priceId: string): string {
const priceMap: Record<string, string> = {
[process.env.STRIPE_PRICE_STARTER!]: 'starter',
[process.env.STRIPE_PRICE_PRO!]: 'pro',
[process.env.STRIPE_PRICE_ENTERPRISE!]: 'enterprise',
};
return priceMap[priceId] ?? 'unknown';
}
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
2. Plan Comparison UI
// src/components/billing/PricingCard.tsx
'use client';
import { useState } from 'react';
import { Check, Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
interface Plan {
name: string;
priceId: string;
monthlyPrice: number;
features: string[];
isCurrentPlan: boolean;
}
export function PricingCard({ plan }: { plan: Plan }) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [preview, setPreview] = useState<{
immediateChargeAmount: number;
prorationCredit: number;
} | null>(null);
async function handleUpgrade() {
setIsLoading(true);
try {
// Fetch proration preview
const previewRes = await fetch(`/api/billing/upgrade-preview?priceId=${plan.priceId}`);
const previewData = await previewRes.json();
setPreview(previewData);
} finally {
setIsLoading(false);
}
}
async function confirmUpgrade() {
setIsLoading(true);
try {
await fetch('/api/billing/upgrade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId: plan.priceId }),
});
router.refresh();
} finally {
setIsLoading(false);
setPreview(null);
}
}
return (
<div className="rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900">{plan.name}</h3>
<p className="mt-1 text-3xl font-bold text-gray-900">
${plan.monthlyPrice}
<span className="text-base font-normal text-gray-500">/mo</span>
</p>
<ul className="mt-4 space-y-2">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-gray-600">
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
{feature}
</li>
))}
</ul>
{plan.isCurrentPlan ? (
<div className="mt-6 w-full rounded-lg bg-gray-100 py-2 text-center text-sm font-medium text-gray-500">
Current plan
</div>
) : preview ? (
<div className="mt-4 rounded-lg bg-blue-50 p-3">
<p className="text-sm text-blue-800">
You'll be charged <strong>${preview.immediateChargeAmount.toFixed(2)}</strong> today
{preview.prorationCredit > 0 && (
<> (includes ${preview.prorationCredit.toFixed(2)} credit for unused time)</>
)}
</p>
<div className="mt-2 flex gap-2">
<button
onClick={confirmUpgrade}
disabled={isLoading}
className="flex-1 rounded bg-blue-600 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{isLoading ? <Loader2 className="mx-auto h-4 w-4 animate-spin" /> : 'Confirm upgrade'}
</button>
<button onClick={() => setPreview(null)} className="text-sm text-gray-500">
Cancel
</button>
</div>
</div>
) : (
<button
onClick={handleUpgrade}
disabled={isLoading}
className="mt-6 w-full rounded-lg bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
>
{isLoading ? <Loader2 className="mx-auto h-4 w-4 animate-spin" /> : `Upgrade to ${plan.name}`}
</button>
)}
</div>
);
}
3. Usage-Based Billing with Stripe Meters
// src/lib/billing/metering.ts
// Report API usage to Stripe Meters
import { stripe } from '../stripe';
const METER_EVENT_NAME = 'api_requests';
export async function reportApiUsage(
stripeCustomerId: string,
count: number,
timestamp?: Date
): Promise<void> {
// Idempotent: use a unique identifier per usage period
const idempotencyKey = `usage-${stripeCustomerId}-${Math.floor(Date.now() / 3600000)}`; // Per hour
await stripe.v2.billing.meterEvents.create(
{
event_name: METER_EVENT_NAME,
payload: {
stripe_customer_id: stripeCustomerId,
value: count.toString(),
},
timestamp: timestamp ? Math.floor(timestamp.getTime() / 1000) : undefined,
},
{ idempotencyKey }
);
}
// Batch report (more efficient โ call once per hour vs per request)
export async function flushUsageBuffer(
usageBuffer: Map<string, number> // customerId โ count
): Promise<void> {
const promises = Array.from(usageBuffer.entries()).map(([customerId, count]) =>
reportApiUsage(customerId, count)
);
await Promise.allSettled(promises);
usageBuffer.clear();
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
4. Invoice History UI
// src/app/settings/billing/invoices/page.tsx
import { stripe } from '../../../../lib/stripe';
import { getServerSession } from 'next-auth';
import { db } from '../../../../lib/db';
import { FileText, Download } from 'lucide-react';
export default async function InvoicesPage() {
const session = await getServerSession();
const account = await db.account.findUnique({
where: { userId: session!.user.id },
select: { stripeCustomerId: true },
});
const invoices = account?.stripeCustomerId
? await stripe.invoices.list({
customer: account.stripeCustomerId,
limit: 24, // Last 2 years monthly
expand: ['data.subscription'],
})
: { data: [] };
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Invoice History</h2>
{invoices.data.length === 0 ? (
<p className="text-gray-500 text-sm">No invoices yet.</p>
) : (
<div className="overflow-hidden rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Date</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Amount</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Status</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Invoice</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{invoices.data.map((invoice) => (
<tr key={invoice.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-700">
{new Date(invoice.created * 1000).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
})}
</td>
<td className="px-4 py-3 font-medium text-gray-900">
${(invoice.total / 100).toFixed(2)} {invoice.currency.toUpperCase()}
</td>
<td className="px-4 py-3">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
invoice.status === 'paid'
? 'bg-green-100 text-green-700'
: invoice.status === 'open'
? 'bg-yellow-100 text-yellow-700'
: 'bg-gray-100 text-gray-600'
}`}>
{invoice.status}
</span>
</td>
<td className="px-4 py-3">
{invoice.invoice_pdf && (
<a
href={invoice.invoice_pdf}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-700"
>
<Download className="w-3.5 h-3.5" />
PDF
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
Cost Reference
| Billing feature | Engineering cost | Stripe cost |
|---|---|---|
| Customer Portal (hosted) | 1โ2 days | Free |
| In-app upgrade flow | 1โ2 weeks | $0 + Stripe fees |
| Usage-based billing | 1โ2 weeks | $0.00045/meter event |
| Invoice history UI | 2โ3 days | Free (Stripe API) |
| Custom billing portal | 4โ8 weeks | Free |
See Also
- Stripe Webhook Handling: Signature Verification and Idempotency
- SaaS Referral System: Tracking, Reward Logic, and Analytics
- SaaS Onboarding Checklist: Interactive UI and Completion Rewards
- SaaS MRR Tracking: Subscription Revenue Metrics and Analytics
- SaaS User Permissions: RBAC, ABAC, and OPA Integration
Working With Viprasol
Building subscription billing from scratch? We implement Stripe Customer Portal for self-serve billing management, in-app upgrade flows with proration previews, usage-based metering, and invoice history UI โ in 2โ3 weeks instead of 2โ3 months, with full webhook handling and edge-case coverage.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.