Back to Blog

SaaS Subscription Upgrades and Downgrades in 2026: Proration

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
13 min read
Updated 2027

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,
  };
}

SaaS - SaaS Subscription Upgrades and Downgrades in 2026: Proration

💡 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

Additional Resources


Our Capabilities

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.

SaaSStripeTypeScriptBillingPostgreSQLSubscriptions
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

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.