Back to Blog

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.

Viprasol Tech Team
December 9, 2026
13 min read

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 featureEngineering costStripe cost
Customer Portal (hosted)1โ€“2 daysFree
In-app upgrade flow1โ€“2 weeks$0 + Stripe fees
Usage-based billing1โ€“2 weeks$0.00045/meter event
Invoice history UI2โ€“3 daysFree (Stripe API)
Custom billing portal4โ€“8 weeksFree

See Also


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.

Talk to our team โ†’ | See our web development services โ†’

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.