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
Recommended Reading
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 |
Related Reading
- 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
Our Approach at 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.
External Resources
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
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.