Back to Blog

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.

Viprasol Tech Team
October 20, 2026
13 min read

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


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.

SaaS engineering โ†’ | Start a project โ†’

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.