Back to Blog

SaaS Subscription Upgrades and Downgrades in 2026: Proration, Stripe Billing, and Upgrade Flows

Implement SaaS subscription plan changes: Stripe proration, mid-cycle upgrades and downgrades, billing portal integration, feature gating on plan change, and prorated invoices.

Viprasol Tech Team
January 17, 2027
13 min read

SaaS Subscription Upgrades and Downgrades in 2026: Proration, Stripe Billing, and Upgrade Flows

Plan changes sound simpleβ€”"upgrade to Pro"β€”but the billing math is subtle. When a customer upgrades mid-cycle, they've already paid for the current period on their old plan. Stripe needs to credit the unused days, charge for the remaining days on the new plan, and issue a prorated invoice. Downgrades add more complexity: do you apply immediately or at period end? What happens to features the customer is currently using?

This post covers the complete implementation: Stripe subscription modification, proration previews so customers see the exact charge before confirming, immediate vs. end-of-period downgrades, feature gating that responds instantly to plan changes, and the webhook handlers that keep your database in sync.


Plan Configuration

// lib/billing/plans.ts
export interface Plan {
  id: string;
  name: string;
  stripePriceId: string;          // Stripe Price ID
  stripePriceIdAnnual?: string;   // Annual billing variant
  monthlyPrice: number;            // In cents
  annualPrice?: number;            // In cents (per year)
  tier: number;                    // 1=Starter, 2=Pro, 3=Enterprise
  limits: {
    seats: number;
    projects: number;
    storageGb: number;
    apiCallsPerMonth: number;
  };
  features: string[];
}

export const PLANS: Record<string, Plan> = {
  starter: {
    id: "starter",
    name: "Starter",
    stripePriceId: process.env.STRIPE_PRICE_STARTER_MONTHLY!,
    stripePriceIdAnnual: process.env.STRIPE_PRICE_STARTER_ANNUAL!,
    monthlyPrice: 2900,   // $29/mo
    annualPrice: 27840,   // $232/yr ($19.33/mo)
    tier: 1,
    limits: { seats: 5, projects: 10, storageGb: 10, apiCallsPerMonth: 10_000 },
    features: ["basic-analytics", "email-support"],
  },
  pro: {
    id: "pro",
    name: "Pro",
    stripePriceId: process.env.STRIPE_PRICE_PRO_MONTHLY!,
    stripePriceIdAnnual: process.env.STRIPE_PRICE_PRO_ANNUAL!,
    monthlyPrice: 9900,   // $99/mo
    annualPrice: 95040,   // $792/yr ($66/mo)
    tier: 2,
    limits: { seats: 25, projects: -1, storageGb: 100, apiCallsPerMonth: 100_000 },
    features: ["basic-analytics", "advanced-analytics", "priority-support", "api-access"],
  },
  enterprise: {
    id: "enterprise",
    name: "Enterprise",
    stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY!,
    monthlyPrice: 29900,  // $299/mo
    tier: 3,
    limits: { seats: -1, projects: -1, storageGb: 1000, apiCallsPerMonth: -1 },
    features: ["basic-analytics", "advanced-analytics", "priority-support", "api-access", "sso", "audit-logs", "custom-contracts"],
  },
} as const;

export function getPlan(planId: string): Plan | undefined {
  return PLANS[planId];
}

export function isUpgrade(fromPlanId: string, toPlanId: string): boolean {
  const from = PLANS[fromPlanId];
  const to = PLANS[toPlanId];
  if (!from || !to) return false;
  return to.tier > from.tier;
}

Database Schema

-- Track every plan change for audit and revenue analysis
CREATE TABLE subscription_plan_changes (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id         UUID NOT NULL REFERENCES teams(id),
  user_id         UUID NOT NULL REFERENCES users(id),  -- Who requested it
  
  from_plan       TEXT NOT NULL,
  to_plan         TEXT NOT NULL,
  change_type     TEXT NOT NULL,  -- 'upgrade' | 'downgrade' | 'reactivation' | 'cancellation'
  
  -- Billing period
  billing_period  TEXT NOT NULL,  -- 'monthly' | 'annual'
  
  -- Stripe details
  stripe_subscription_id TEXT NOT NULL,
  stripe_invoice_id      TEXT,    -- Invoice generated for prorated charge
  prorated_amount_cents  INTEGER, -- Positive = charge, negative = credit
  
  -- Timing
  effective_at    TIMESTAMPTZ NOT NULL,  -- When the change takes effect
  applied_at      TIMESTAMPTZ,          -- When webhook confirmed it
  
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_plan_changes_team ON subscription_plan_changes(team_id, created_at DESC);

πŸš€ 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

Proration Preview

Before the customer confirms, show them exactly what they'll be charged:

// lib/billing/proration.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

interface ProrationPreview {
  immediateChargecents: number;   // What gets charged/credited now
  newMonthlyPricecents: number;   // New recurring amount
  nextInvoiceDateTs: number;      // Unix timestamp
  lineItems: Array<{
    description: string;
    amountCents: number;
  }>;
}

export async function previewPlanChange(
  stripeSubscriptionId: string,
  newStripePriceId: string
): Promise<ProrationPreview> {
  const subscription = await stripe.subscriptions.retrieve(stripeSubscriptionId);
  const currentItemId = subscription.items.data[0].id;
  const prorationDate = Math.floor(Date.now() / 1000); // Current timestamp

  // Retrieve upcoming invoice with the proposed change applied
  const upcomingInvoice = await stripe.invoices.retrieveUpcoming({
    subscription: stripeSubscriptionId,
    subscription_items: [
      {
        id: currentItemId,
        price: newStripePriceId,
      },
    ],
    subscription_proration_date: prorationDate,
  });

  // Extract the prorated line items (not the next cycle charge)
  const proratedLines = upcomingInvoice.lines.data.filter(
    (line) => line.proration
  );

  const immediateChargeTotal = proratedLines.reduce(
    (sum, line) => sum + line.amount,
    0
  );

  return {
    immediateChargecents: immediateChargeTotal,
    newMonthlyPricecents: upcomingInvoice.lines.data
      .find((l) => !l.proration)?.amount ?? 0,
    nextInvoiceDateTs: upcomingInvoice.next_payment_attempt ?? 0,
    lineItems: proratedLines.map((line) => ({
      description: line.description ?? "",
      amountCents: line.amount,
    })),
  };
}

Preview API endpoint:

// app/api/billing/plan-change/preview/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { previewPlanChange } from "@/lib/billing/proration";
import { PLANS } from "@/lib/billing/plans";

export async function POST(req: NextRequest) {
  const user = await getCurrentUser();
  if (!user?.isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });

  const { toPlanId } = await req.json();

  const plan = PLANS[toPlanId];
  if (!plan) return NextResponse.json({ error: "Invalid plan" }, { status: 400 });

  const subscription = await db.subscription.findFirst({
    where: { teamId: user.teamId, status: "active" },
  });

  if (!subscription?.stripeSubscriptionId) {
    return NextResponse.json({ error: "No active subscription" }, { status: 400 });
  }

  const preview = await previewPlanChange(
    subscription.stripeSubscriptionId,
    plan.stripePriceId
  );

  return NextResponse.json({
    preview,
    newPlan: {
      id: plan.id,
      name: plan.name,
      monthlyPrice: plan.monthlyPrice,
    },
  });
}

Executing the Plan Change

// lib/billing/plan-change.ts
import Stripe from "stripe";
import { db } from "@/lib/db";
import { isUpgrade, PLANS } from "./plans";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

interface PlanChangeOptions {
  teamId: string;
  userId: string;
  fromPlanId: string;
  toPlanId: string;
  stripeSubscriptionId: string;
  billingPeriod: "monthly" | "annual";
}

export async function changePlan({
  teamId,
  userId,
  fromPlanId,
  toPlanId,
  stripeSubscriptionId,
  billingPeriod,
}: PlanChangeOptions) {
  const toPlan = PLANS[toPlanId];
  if (!toPlan) throw new Error(`Unknown plan: ${toPlanId}`);

  const newPriceId = billingPeriod === "annual" && toPlan.stripePriceIdAnnual
    ? toPlan.stripePriceIdAnnual
    : toPlan.stripePriceId;

  const upgrade = isUpgrade(fromPlanId, toPlanId);
  const subscription = await stripe.subscriptions.retrieve(stripeSubscriptionId);
  const currentItemId = subscription.items.data[0].id;

  let updatedSubscription: Stripe.Subscription;

  if (upgrade) {
    // Upgrades: apply immediately with proration
    updatedSubscription = await stripe.subscriptions.update(stripeSubscriptionId, {
      items: [{ id: currentItemId, price: newPriceId }],
      proration_behavior: "create_prorations",  // Charge/credit immediately
      payment_behavior: "error_if_incomplete",   // Fail if payment fails
    });
  } else {
    // Downgrades: schedule for end of current billing period
    // Customer keeps full access through the period they paid for
    updatedSubscription = await stripe.subscriptions.update(stripeSubscriptionId, {
      items: [{ id: currentItemId, price: newPriceId }],
      proration_behavior: "none",              // No proration β€” takes effect next cycle
      billing_cycle_anchor: "unchanged",
    });
  }

  // Record the plan change for audit trail
  const effectiveAt = upgrade
    ? new Date()
    : new Date(updatedSubscription.current_period_end * 1000);

  await db.$transaction([
    db.subscriptionPlanChange.create({
      data: {
        teamId,
        userId,
        fromPlan: fromPlanId,
        toPlan: toPlanId,
        changeType: upgrade ? "upgrade" : "downgrade",
        billingPeriod,
        stripeSubscriptionId,
        effectiveAt,
      },
    }),
    // For upgrades: update plan immediately so features unlock now
    ...(upgrade
      ? [
          db.subscription.update({
            where: { stripeSubscriptionId },
            data: { plan: toPlanId, updatedAt: new Date() },
          }),
        ]
      : [
          // For downgrades: record pending plan, keep current plan active
          db.subscription.update({
            where: { stripeSubscriptionId },
            data: { pendingPlan: toPlanId, updatedAt: new Date() },
          }),
        ]),
  ]);

  return {
    effective: upgrade ? "immediate" : "end_of_period",
    effectiveAt,
    newPlan: toPlanId,
  };
}

πŸ’‘ 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

Webhook Handler: Apply Scheduled Downgrade

// app/api/webhooks/stripe/route.ts (excerpt)
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

async function handleInvoicePaid(invoice: Stripe.Invoice) {
  const subscriptionId = typeof invoice.subscription === "string"
    ? invoice.subscription
    : invoice.subscription?.id;

  if (!subscriptionId) return;

  const subscription = await db.subscription.findFirst({
    where: { stripeSubscriptionId: subscriptionId },
  });

  if (!subscription) return;

  // If there's a pending downgrade, apply it now that the new period has started
  if (subscription.pendingPlan) {
    await db.subscription.update({
      where: { id: subscription.id },
      data: {
        plan: subscription.pendingPlan,
        pendingPlan: null,
        updatedAt: new Date(),
      },
    });

    // Record that the downgrade was applied
    await db.subscriptionPlanChange.updateMany({
      where: {
        stripeSubscriptionId: subscriptionId,
        toPlan: subscription.pendingPlan,
        appliedAt: null,
      },
      data: { appliedAt: new Date() },
    });
  }

  // Update period dates
  const stripeSubscription = await stripe.subscriptions.retrieve(subscriptionId);
  await db.subscription.update({
    where: { id: subscription.id },
    data: {
      currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
      currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
    },
  });
}

Feature Gating Based on Plan

// lib/billing/feature-gate.ts
import { db } from "@/lib/db";
import { PLANS } from "./plans";
import { cache } from "react";

export const getTeamPlan = cache(async (teamId: string) => {
  const subscription = await db.subscription.findFirst({
    where: { teamId, status: "active" },
    select: { plan: true, currentPeriodEnd: true },
  });
  return subscription?.plan ?? "starter";
});

export async function checkFeatureAccess(
  teamId: string,
  feature: string
): Promise<{ allowed: boolean; requiredPlan?: string }> {
  const currentPlan = await getTeamPlan(teamId);
  const plan = PLANS[currentPlan];

  if (!plan) return { allowed: false };
  if (plan.features.includes(feature)) return { allowed: true };

  // Find the cheapest plan that includes this feature
  const requiredPlan = Object.values(PLANS)
    .sort((a, b) => a.tier - b.tier)
    .find((p) => p.features.includes(feature));

  return { allowed: false, requiredPlan: requiredPlan?.id };
}

export async function checkLimitAccess(
  teamId: string,
  resource: keyof typeof PLANS.starter.limits,
  currentCount: number
): Promise<{ allowed: boolean; limit: number; used: number }> {
  const currentPlan = await getTeamPlan(teamId);
  const plan = PLANS[currentPlan];
  const limit = plan?.limits[resource] ?? 0;

  if (limit === -1) return { allowed: true, limit: -1, used: currentCount }; // Unlimited

  return {
    allowed: currentCount < limit,
    limit,
    used: currentCount,
  };
}

Using feature gates in API routes:

// app/api/analytics/advanced/route.ts
import { checkFeatureAccess } from "@/lib/billing/feature-gate";

export async function GET(req: NextRequest) {
  const user = await getCurrentUser();
  const { allowed, requiredPlan } = await checkFeatureAccess(
    user.teamId,
    "advanced-analytics"
  );

  if (!allowed) {
    return NextResponse.json(
      {
        error: "Feature not available on your current plan",
        requiredPlan,
        upgradeUrl: "/settings/billing",
      },
      { status: 402 }
    );
  }

  // Proceed with advanced analytics...
}

Upgrade Flow UI

// components/PlanUpgradeModal/PlanUpgradeModal.tsx
"use client";

import { useState } from "react";
import { formatCurrency } from "@/lib/utils";

export function PlanUpgradeModal({
  fromPlan,
  toPlan,
  onClose,
  onSuccess,
}: {
  fromPlan: string;
  toPlan: string;
  onClose: () => void;
  onSuccess: () => void;
}) {
  const [preview, setPreview] = useState<any>(null);
  const [isLoadingPreview, setIsLoadingPreview] = useState(false);
  const [isConfirming, setIsConfirming] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Load proration preview on mount
  useState(() => {
    setIsLoadingPreview(true);
    fetch("/api/billing/plan-change/preview", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ toPlanId: toPlan }),
    })
      .then((r) => r.json())
      .then(setPreview)
      .catch(() => setError("Failed to load pricing preview"))
      .finally(() => setIsLoadingPreview(false));
  });

  const handleConfirm = async () => {
    setIsConfirming(true);
    setError(null);
    try {
      const res = await fetch("/api/billing/plan-change", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ toPlanId: toPlan }),
      });
      if (!res.ok) throw new Error((await res.json()).error);
      onSuccess();
    } catch (e: any) {
      setError(e.message ?? "Plan change failed");
    } finally {
      setIsConfirming(false);
    }
  };

  return (
    <div role="dialog" aria-modal="true" aria-label={`Upgrade to ${toPlan}`}
      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
      <div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
        <h2 className="text-xl font-semibold mb-4">Upgrade to {toPlan}</h2>

        {isLoadingPreview && (
          <div className="h-24 flex items-center justify-center">
            <div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
          </div>
        )}

        {preview && (
          <div className="space-y-4">
            <div className="bg-gray-50 rounded-lg p-4 space-y-2">
              {preview.preview.lineItems.map((item: any, i: number) => (
                <div key={i} className="flex justify-between text-sm">
                  <span className="text-gray-600">{item.description}</span>
                  <span className={item.amountCents < 0 ? "text-green-600" : "text-gray-900"}>
                    {item.amountCents < 0 ? "βˆ’" : ""}
                    {formatCurrency(Math.abs(item.amountCents))}
                  </span>
                </div>
              ))}
              <div className="border-t pt-2 flex justify-between font-semibold">
                <span>Due today</span>
                <span>
                  {formatCurrency(Math.max(0, preview.preview.immediateChargecents))}
                </span>
              </div>
            </div>

            <p className="text-sm text-gray-500">
              Starting next billing cycle: {formatCurrency(preview.newPlan.monthlyPrice)}/month
            </p>
          </div>
        )}

        {error && (
          <div role="alert" className="bg-red-50 text-red-700 rounded-lg p-3 text-sm mt-4">
            {error}
          </div>
        )}

        <div className="flex gap-3 mt-6">
          <button onClick={onClose} className="flex-1 py-2.5 border border-gray-300 rounded-lg text-sm font-medium hover:bg-gray-50">
            Cancel
          </button>
          <button
            onClick={handleConfirm}
            disabled={isConfirming || isLoadingPreview}
            className="flex-1 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
          >
            {isConfirming ? "Upgrading..." : "Confirm Upgrade"}
          </button>
        </div>
      </div>
    </div>
  );
}

Cost and Timeline

ComponentTimelineCost (USD)
Plan config + proration preview1–2 days$800–$1,600
Upgrade/downgrade execution1–2 days$800–$1,600
Webhook handlers1 day$600–$1,000
Feature gating system1–2 days$800–$1,600
Upgrade UI modal1–2 days$800–$1,600
Full billing plan change system1.5–2 weeks$6,000–$10,000

See Also


Working With Viprasol

We build subscription billing systems for SaaS products β€” from plan configuration through proration logic, feature gating, and payment failure recovery. Our team has shipped billing systems handling millions of dollars in monthly recurring revenue.

What we deliver:

  • Stripe subscription setup with proration-aware upgrades and downgrades
  • Proration preview so customers see exact charges before confirming
  • Feature gating tied to plan tier with upgrade prompts
  • Webhook handlers that keep your database in sync with Stripe
  • Billing portal integration for self-serve plan management

Explore our SaaS development services or contact us to build your subscription billing system.

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.