SaaS Self-Serve Billing Portal: Plan Management, Invoices, and Stripe Customer Portal
Build a self-serve SaaS billing portal: integrate Stripe Customer Portal for plan changes and payment updates, implement custom plan upgrade flows, display invoice history, and handle proration calculations for mid-cycle plan changes.
A self-serve billing portal reduces support tickets by letting customers manage their own subscriptions โ upgrade plans, update payment methods, download invoices, and cancel without contacting support. Stripe provides a hosted portal for common tasks; you build custom UI for the upgrade flows that need product context.
Architecture: Stripe Portal vs Custom UI
Stripe Customer Portal (hosted by Stripe):
โ
Update payment method
โ
View invoice history
โ
Cancel subscription
โ
Basic plan switching (if configured)
โ No product context (can't show "you're using 8/10 projects")
โ No custom upgrade incentives
Custom Billing UI (you build it):
โ
Show current usage alongside plan limits
โ
Contextual upgrade prompts ("You're at 80% of your limit")
โ
Custom proration display
โ
Trial-to-paid conversion flow
โ You maintain it
โ PCI compliance complexity for payment method updates
Best pattern: Custom plan overview + upgrade flow โ Stripe Portal for payment/invoice management
Stripe Customer Portal Integration
// src/app/billing/actions.ts
"use server";
import Stripe from "stripe";
import { redirect } from "next/navigation";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Generate a Stripe Customer Portal session
export async function openBillingPortal(returnPath: string = "/billing"): Promise<never> {
const user = await requireAuth();
const tenant = await getTenant(user.tenantId);
if (!tenant.stripeCustomerId) {
throw new Error("No Stripe customer associated with this account");
}
const session = await stripe.billingPortal.sessions.create({
customer: tenant.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}${returnPath}`,
// Customize which features are available
// (alternatively configure in Stripe Dashboard)
configuration: await getPortalConfiguration(),
});
redirect(session.url);
}
// Create or cache a portal configuration
async function getPortalConfiguration(): Promise<string> {
// Cache the configuration ID โ it doesn't change
const cached = await redis.get("stripe:portal-config");
if (cached) return cached;
const config = await stripe.billingPortal.configurations.create({
features: {
invoice_history: { enabled: true },
payment_method_update: { enabled: true },
subscription_cancel: {
enabled: true,
mode: "at_period_end", // Cancel at end of billing period
cancellation_reason: {
enabled: true,
options: [
"too_expensive",
"missing_features",
"switched_service",
"unused",
"other",
],
},
},
subscription_pause: { enabled: false }, // Disable pause โ use dunning instead
subscription_update: {
enabled: true,
proration_behavior: "create_prorations",
default_allowed_updates: ["price"],
products: await getAllowedProducts(),
},
},
business_profile: {
headline: "Manage your Viprasol subscription",
privacy_policy_url: "https://viprasol.com/privacy",
terms_of_service_url: "https://viprasol.com/terms",
},
});
await redis.setex("stripe:portal-config", 86400, config.id); // Cache 24h
return config.id;
}
๐ 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
Current Plan Overview Page
// src/app/billing/page.tsx
import { Suspense } from "react";
import { openBillingPortal } from "./actions";
export default async function BillingPage() {
const user = await requireAuth();
const [subscription, usage, invoices] = await Promise.all([
getSubscription(user.tenantId),
getUsage(user.tenantId),
getRecentInvoices(user.tenantId, 3),
]);
return (
<div className="max-w-4xl mx-auto p-6 space-y-8">
<h1 className="text-2xl font-bold">Billing & Subscription</h1>
{/* Current Plan */}
<section className="border rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">{subscription.planName} Plan</h2>
<p className="text-gray-500">
{subscription.cancelAtPeriodEnd
? `Cancels on ${formatDate(subscription.currentPeriodEnd)}`
: `Renews ${formatDate(subscription.currentPeriodEnd)}`}
</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold">
${subscription.amount / 100}/{subscription.interval}
</p>
</div>
</div>
{/* Usage meters */}
<div className="mt-6 grid grid-cols-2 gap-4">
<UsageMeter
label="Projects"
used={usage.projects}
limit={subscription.limits.projects}
/>
<UsageMeter
label="Team Members"
used={usage.teamMembers}
limit={subscription.limits.teamMembers}
/>
<UsageMeter
label="API Calls (this month)"
used={usage.apiCalls}
limit={subscription.limits.apiCallsPerMonth}
/>
<UsageMeter
label="Storage"
used={usage.storageGb}
limit={subscription.limits.storageGb}
unit="GB"
/>
</div>
{/* Upgrade CTA when approaching limits */}
{isApproachingLimit(usage, subscription.limits) && (
<UpgradeBanner currentPlan={subscription.planId} />
)}
</section>
{/* Plan comparison + upgrade */}
<Suspense fallback={<PlansSkeleton />}>
<PlanSelector currentPlanId={subscription.planId} />
</Suspense>
{/* Manage via Stripe Portal */}
<section className="border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Payment & Invoices</h2>
<div className="space-y-3">
{invoices.map((invoice) => (
<div key={invoice.id} className="flex justify-between text-sm">
<span>{formatDate(invoice.date)}</span>
<span>${invoice.amount / 100}</span>
<a
href={invoice.pdfUrl}
className="text-blue-600 hover:underline"
target="_blank"
rel="noopener noreferrer"
>
PDF
</a>
</div>
))}
</div>
<form action={openBillingPortal.bind(null, "/billing")}>
<button
type="submit"
className="mt-4 text-sm text-blue-600 hover:underline"
>
View all invoices & manage payment method โ
</button>
</form>
</section>
</div>
);
}
function UsageMeter({
label,
used,
limit,
unit = "",
}: {
label: string;
used: number;
limit: number;
unit?: string;
}) {
const pct = limit === Infinity ? 0 : Math.min(100, (used / limit) * 100);
const isWarning = pct >= 80;
return (
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">{label}</span>
<span className={isWarning ? "text-amber-600 font-medium" : "text-gray-500"}>
{limit === Infinity ? `${used}${unit}` : `${used}/${limit}${unit}`}
</span>
</div>
{limit !== Infinity && (
<div className="h-2 bg-gray-100 rounded-full">
<div
className={`h-2 rounded-full transition-all ${
pct >= 80 ? "bg-amber-500" : "bg-blue-500"
}`}
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
);
}
Custom Plan Upgrade Flow
// src/app/billing/upgrade/actions.ts
"use server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function upgradePlan(
prevState: { success: boolean; message?: string },
formData: FormData
): Promise<{ success: boolean; message?: string }> {
const user = await requireAuth();
const tenant = await getTenant(user.tenantId);
const newPriceId = formData.get("priceId") as string;
if (!tenant.stripeSubscriptionId) {
return { success: false, message: "No active subscription found" };
}
try {
const subscription = await stripe.subscriptions.retrieve(
tenant.stripeSubscriptionId
);
const currentItem = subscription.items.data[0];
// Proration: charge/credit for the remaining billing period
await stripe.subscriptions.update(tenant.stripeSubscriptionId, {
items: [
{
id: currentItem.id,
price: newPriceId,
},
],
proration_behavior: "create_prorations", // Charge diff immediately
// For immediate upgrade:
billing_cycle_anchor: "unchanged", // Don't reset billing date
});
// Calculate what they'll be charged now (proration)
const upcomingInvoice = await stripe.invoices.retrieveUpcoming({
customer: tenant.stripeCustomerId,
subscription: tenant.stripeSubscriptionId,
subscription_items: [
{ id: currentItem.id, price: newPriceId },
],
subscription_proration_behavior: "create_prorations",
});
const proratedAmount = upcomingInvoice.amount_due;
// Update our database
await updateTenantPlan(tenant.id, newPriceId);
revalidatePath("/billing");
return {
success: true,
message: `Plan upgraded. ${proratedAmount > 0 ? `$${proratedAmount / 100} will be charged for the remainder of this billing period.` : "No charge for this billing period."}`,
};
} catch (error) {
console.error("Plan upgrade failed:", error);
return {
success: false,
message: "Upgrade failed. Please try again or contact support.",
};
}
}
๐ก 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
Proration Preview Component
// src/app/billing/upgrade/ProrationPreview.tsx
"use client";
import { useEffect, useState } from "react";
interface ProrationPreview {
immediateCharge: number;
newMonthlyAmount: number;
currency: string;
}
export function ProrationPreview({
currentPriceId,
newPriceId,
}: {
currentPriceId: string;
newPriceId: string;
}) {
const [preview, setPreview] = useState<ProrationPreview | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!newPriceId || newPriceId === currentPriceId) return;
setLoading(true);
fetch("/api/billing/proration-preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newPriceId }),
})
.then((r) => r.json())
.then((data) => {
setPreview(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, [newPriceId, currentPriceId]);
if (loading) return <div className="h-12 animate-pulse bg-gray-100 rounded" />;
if (!preview) return null;
return (
<div className="bg-blue-50 rounded-lg p-4 text-sm">
<p className="font-medium text-blue-900">Upgrade Summary</p>
<div className="mt-2 space-y-1 text-blue-700">
{preview.immediateCharge > 0 && (
<p>Charged today: <strong>${(preview.immediateCharge / 100).toFixed(2)}</strong> (prorated)</p>
)}
<p>New monthly amount: <strong>${(preview.newMonthlyAmount / 100).toFixed(2)}</strong></p>
</div>
</div>
);
}
See Also
- SaaS Billing Engineering โ Stripe subscription setup
- SaaS Dunning Management โ failed payment recovery
- SaaS Revenue Recognition โ MRR from plan changes
- SaaS Pricing Strategy โ pricing model design
Working With Viprasol
A self-serve billing portal reduces involuntary churn, eliminates support tickets for plan changes, and gives customers the transparency that builds trust. We build billing portals that integrate Stripe Customer Portal for payment management and custom upgrade flows for conversion-optimized plan switching โ with usage meters, proration previews, and contextual upgrade prompts.
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.