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.
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
| Component | Timeline | Cost (USD) |
|---|---|---|
| Plan config + proration preview | 1β2 days | $800β$1,600 |
| Upgrade/downgrade execution | 1β2 days | $800β$1,600 |
| Webhook handlers | 1 day | $600β$1,000 |
| Feature gating system | 1β2 days | $800β$1,600 |
| Upgrade UI modal | 1β2 days | $800β$1,600 |
| Full billing plan change system | 1.5β2 weeks | $6,000β$10,000 |
See Also
- Stripe Webhook Handling β Idempotent webhook processing
- SaaS Billing Portal β Customer-facing billing management
- SaaS Dunning Management β Failed payment retry workflows
- PostgreSQL Advisory Locks β Preventing concurrent plan changes
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.
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.