How to Implement Prorated Stripe Subscription Upgrades and Downgrades
When a customer clicks "Upgrade to Pro" on your SaaS pricing page, what happens behind the scenes can make or break their experience. A poorly implemented subsc...
Osmoto Team
Senior Software Engineer

When a customer clicks "Upgrade to Pro" on your SaaS pricing page, what happens behind the scenes can make or break their experience. A poorly implemented subscription change can result in double billing, incorrect proration calculations, or worse—failed payments that churn paying customers. I've seen businesses lose thousands in revenue from botched upgrade flows that should have been straightforward.
Stripe's subscription proration system handles most of the heavy lifting, but the devil is in the implementation details. How do you handle mid-cycle upgrades when usage-based billing is involved? What happens when a customer downgrades immediately after upgrading? How do you prevent race conditions when processing multiple subscription changes?
This guide covers the complete implementation of prorated subscription upgrades and downgrades, including the billing edge cases that can catch even experienced developers off guard. We'll walk through the technical implementation, webhook handling, and the business logic needed to create a bulletproof subscription change system.
Understanding Stripe's Proration Logic
Before diving into implementation, it's crucial to understand exactly how Stripe calculates prorations. Stripe uses a time-based proration system that calculates the unused time on the current subscription and applies credit toward the new plan.
Here's how the calculation works:
// Simplified proration calculation const unusedTime = periodEnd - changeTime; const totalPeriodTime = periodEnd - periodStart; const proratedCredit = (unusedTime / totalPeriodTime) * currentPlanAmount; const immediateCharge = newPlanAmount - proratedCredit;
However, Stripe's actual implementation includes several nuances:
- Proration behavior is controlled by the
proration_behaviorparameter - Billing cycle alignment affects when charges occur
- Invoice timing can be immediate or deferred to the next billing cycle
Let's examine a real-world scenario: A customer upgrades from a $29/month Basic plan to a $99/month Pro plan on day 15 of their billing cycle.
// Example proration calculation const basicPlanAmount = 2900; // $29.00 in cents const proPlanAmount = 9900; // $99.00 in cents const daysUsed = 15; const daysInMonth = 30; const remainingValue = basicPlanAmount * (15 / 30); // $14.50 credit const immediateCharge = proPlanAmount - remainingValue; // $84.50
The customer receives a $14.50 credit for the unused portion of their Basic plan and pays $84.50 immediately for the Pro plan upgrade.
Implementing Subscription Upgrades
The most common subscription change is an upgrade—customers moving to a higher-tier plan. Here's a robust implementation that handles the technical and business logic:
Basic Upgrade Implementation
interface UpgradeSubscriptionParams { subscriptionId: string; newPriceId: string; effectiveDate?: 'immediate' | 'next_billing_cycle'; prorationBehavior?: 'create_prorations' | 'none' | 'always_invoice'; } async function upgradeSubscription({ subscriptionId, newPriceId, effectiveDate = 'immediate', prorationBehavior = 'create_prorations' }: UpgradeSubscriptionParams) { try { // First, retrieve the current subscription const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['items.data.price'] }); if (!subscription.items.data[0]) { throw new Error('Subscription has no items'); } const currentItem = subscription.items.data[0]; // Update the subscription item with the new price const updatedSubscription = await stripe.subscriptions.update(subscriptionId, { items: [{ id: currentItem.id, price: newPriceId, }], proration_behavior: prorationBehavior, // For immediate changes ...(effectiveDate === 'immediate' && { proration_date: Math.floor(Date.now() / 1000) }) }); return { subscription: updatedSubscription, success: true }; } catch (error) { console.error('Upgrade failed:', error); throw new Error(`Subscription upgrade failed: ${error.message}`); } }
Handling Multiple Subscription Items
Many SaaS applications have complex pricing with multiple components—base plans plus add-ons. Here's how to handle upgrades with multiple subscription items:
async function upgradeComplexSubscription({ subscriptionId, planChanges, addOnChanges = [] }: { subscriptionId: string; planChanges: { itemId: string; newPriceId: string }; addOnChanges: Array<{ action: 'add' | 'remove' | 'update'; priceId: string; itemId?: string; quantity?: number }>; }) { const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['items.data.price'] }); const updateItems = []; // Update the main plan const mainItem = subscription.items.data.find(item => item.id === planChanges.itemId); if (mainItem) { updateItems.push({ id: mainItem.id, price: planChanges.newPriceId }); } // Handle add-on changes for (const addOnChange of addOnChanges) { switch (addOnChange.action) { case 'add': updateItems.push({ price: addOnChange.priceId, quantity: addOnChange.quantity || 1 }); break; case 'remove': if (addOnChange.itemId) { updateItems.push({ id: addOnChange.itemId, deleted: true }); } break; case 'update': if (addOnChange.itemId) { updateItems.push({ id: addOnChange.itemId, price: addOnChange.priceId, quantity: addOnChange.quantity }); } break; } } return await stripe.subscriptions.update(subscriptionId, { items: updateItems, proration_behavior: 'create_prorations' }); }
Implementing Subscription Downgrades
Downgrades require more careful handling than upgrades because they often involve refunding money or applying credits. The business logic around downgrades can vary significantly based on your refund policy.
Basic Downgrade with Credit Application
async function downgradeSubscription({ subscriptionId, newPriceId, refundPolicy = 'credit' // 'credit' | 'refund' | 'none' }: { subscriptionId: string; newPriceId: string; refundPolicy?: 'credit' | 'refund' | 'none'; }) { const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['items.data.price', 'latest_invoice'] }); const currentItem = subscription.items.data[0]; // Calculate the proration amount for business logic const currentPrice = currentItem.price.unit_amount; const newPrice = await stripe.prices.retrieve(newPriceId); const priceDifference = currentPrice - newPrice.unit_amount; let prorationBehavior: string; switch (refundPolicy) { case 'credit': // Apply credit to customer's account for next billing cycle prorationBehavior = 'create_prorations'; break; case 'refund': // Issue immediate refund (requires additional logic) prorationBehavior = 'create_prorations'; await handleImmediateRefund(subscription, priceDifference); break; case 'none': // No proration, change takes effect next billing cycle prorationBehavior = 'none'; break; } const updatedSubscription = await stripe.subscriptions.update(subscriptionId, { items: [{ id: currentItem.id, price: newPriceId, }], proration_behavior: prorationBehavior }); return updatedSubscription; } async function handleImmediateRefund(subscription: any, refundAmount: number) { if (refundAmount <= 0) return; // Find the most recent successful payment const latestInvoice = subscription.latest_invoice; if (latestInvoice?.payment_intent?.id) { const refund = await stripe.refunds.create({ payment_intent: latestInvoice.payment_intent.id, amount: Math.floor(refundAmount * (subscription.current_period_end - Date.now() / 1000) / (subscription.current_period_end - subscription.current_period_start)) }); return refund; } }
Preventing Immediate Downgrade Abuse
Some customers might try to game the system by upgrading to access premium features, then immediately downgrading to get a refund while retaining access. Here's how to implement a cooling-off period:
async function validateDowngrade(subscriptionId: string, customerId: string): Promise<boolean> { // Check for recent upgrades within the last 24 hours const recentUpgrades = await stripe.events.list({ type: 'customer.subscription.updated', created: { gte: Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000) }, limit: 10 }); const customerUpgrades = recentUpgrades.data.filter(event => { const subscription = event.data.object as any; return subscription.customer === customerId && subscription.id === subscriptionId; }); if (customerUpgrades.length > 0) { // Check if this was an upgrade (price increase) const upgradeEvent = customerUpgrades[0]; const previousPrice = upgradeEvent.data.previous_attributes?.items?.data?.[0]?.price?.unit_amount; const currentPrice = upgradeEvent.data.object.items.data[0].price.unit_amount; if (currentPrice > previousPrice) { return false; // Block downgrade within 24 hours of upgrade } } return true; }
Handling Billing Edge Cases
Real-world subscription changes involve numerous edge cases that can break your billing logic if not handled properly. Here are the most critical scenarios to account for:
Failed Payment During Upgrade
When a customer upgrades but their payment method fails, Stripe will retry the payment according to your retry settings. However, you need to handle the interim state:
async function handleFailedUpgradePayment(subscriptionId: string) { const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (subscription.status === 'past_due') { // Option 1: Revert to previous plan await revertToPreviousPlan(subscriptionId); // Option 2: Maintain upgrade but restrict access await updateCustomerAccess(subscription.customer, 'restricted'); // Option 3: Allow grace period await setGracePeriod(subscriptionId, 7); // 7 days } } async function revertToPreviousPlan(subscriptionId: string) { // Retrieve the previous plan from your database or Stripe metadata const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['metadata'] }); const previousPriceId = subscription.metadata.previous_price_id; if (previousPriceId) { await stripe.subscriptions.update(subscriptionId, { items: [{ id: subscription.items.data[0].id, price: previousPriceId }], proration_behavior: 'none' // Don't create additional charges }); } }
Race Conditions in Rapid Plan Changes
Customers sometimes make multiple plan changes in quick succession, which can create race conditions. Implement a locking mechanism:
const subscriptionLocks = new Map<string, Promise<any>>(); async function safeSubscriptionUpdate(subscriptionId: string, updateFn: () => Promise<any>) { // Check if there's already an update in progress if (subscriptionLocks.has(subscriptionId)) { await subscriptionLocks.get(subscriptionId); } // Create a new lock for this update const updatePromise = updateFn(); subscriptionLocks.set(subscriptionId, updatePromise); try { const result = await updatePromise; return result; } finally { // Release the lock subscriptionLocks.delete(subscriptionId); } } // Usage await safeSubscriptionUpdate(subscriptionId, async () => { return await upgradeSubscription({ subscriptionId, newPriceId: 'price_new_plan' }); });
Proration with Usage-Based Billing
When your subscription includes usage-based components, proration becomes more complex:
async function upgradeWithUsageComponents({ subscriptionId, newBasePriceId, usageRecords }: { subscriptionId: string; newBasePriceId: string; usageRecords: Array<{ subscriptionItemId: string; quantity: number; timestamp: number }>; }) { // First, report any pending usage for (const usage of usageRecords) { await stripe.subscriptionItems.createUsageRecord(usage.subscriptionItemId, { quantity: usage.quantity, timestamp: usage.timestamp, action: 'increment' }); } // Wait a moment for usage to be processed await new Promise(resolve => setTimeout(resolve, 1000)); // Now perform the upgrade const subscription = await stripe.subscriptions.retrieve(subscriptionId); const baseItem = subscription.items.data.find(item => item.price.recurring?.usage_type !== 'metered' ); if (baseItem) { return await stripe.subscriptions.update(subscriptionId, { items: [{ id: baseItem.id, price: newBasePriceId }], proration_behavior: 'create_prorations' }); } }
Webhook Handling for Subscription Changes
Proper webhook handling is crucial for maintaining data consistency during subscription changes. Here are the key events to monitor:
async function handleSubscriptionWebhook(event: Stripe.Event) { switch (event.type) { case 'customer.subscription.updated': await handleSubscriptionUpdated(event.data.object as Stripe.Subscription); break; case 'invoice.payment_succeeded': await handlePaymentSucceeded(event.data.object as Stripe.Invoice); break; case 'invoice.payment_failed': await handlePaymentFailed(event.data.object as Stripe.Invoice); break; case 'customer.subscription.trial_will_end': await handleTrialEnding(event.data.object as Stripe.Subscription); break; } } async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { // Update your database with the new subscription details await updateDatabaseSubscription(subscription); // Update customer access based on new plan await updateCustomerAccess(subscription.customer, subscription.items.data[0].price.id); // Send confirmation email for plan changes if (subscription.metadata.plan_change_reason) { await sendPlanChangeConfirmation(subscription.customer, { oldPlan: subscription.metadata.previous_plan, newPlan: subscription.items.data[0].price.nickname, effectiveDate: new Date(subscription.current_period_start * 1000) }); } } async function handlePaymentFailed(invoice: Stripe.Invoice) { if (invoice.billing_reason === 'subscription_update') { // This was a failed payment for a subscription change const subscriptionId = invoice.subscription as string; // Implement your failed payment logic await handleFailedUpgradePayment(subscriptionId); // Notify customer await sendPaymentFailureNotification(invoice.customer, { amount: invoice.amount_due, nextRetry: invoice.next_payment_attempt }); } }
Common Pitfalls and How to Avoid Them
After implementing dozens of subscription billing systems, I've seen the same mistakes repeated across different projects. Here are the most critical pitfalls to avoid:
Pitfall 1: Not Storing Previous Plan Information
When a subscription change fails or needs to be reverted, you need access to the previous plan details. Always store this information:
async function upgradeWithHistory(subscriptionId: string, newPriceId: string) { const subscription = await stripe.subscriptions.retrieve(subscriptionId); const currentPriceId = subscription.items.data[0].price.id; // Store previous plan in metadata before upgrading await stripe.subscriptions.update(subscriptionId, { metadata: { previous_price_id: currentPriceId, change_timestamp: Date.now().toString(), change_reason: 'customer_upgrade' }, items: [{ id: subscription.items.data[0].id, price: newPriceId }] }); }
Pitfall 2: Ignoring Proration Date Precision
Stripe's proration calculations are precise to the second. If you're implementing custom proration logic, make sure your calculations match:
// Wrong - using Date.now() directly const prorationDate = Date.now(); // Correct - converting to Unix timestamp const prorationDate = Math.floor(Date.now() / 1000); await stripe.subscriptions.update(subscriptionId, { proration_date: prorationDate, // ... other parameters });
Pitfall 3: Not Handling Incomplete Subscriptions
When payment fails during a subscription change, Stripe may create an incomplete subscription. Always check the subscription status:
async function validateSubscriptionStatus(subscriptionId: string) { const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (subscription.status === 'incomplete') { // Handle incomplete subscription const latestInvoice = await stripe.invoices.retrieve(subscription.latest_invoice as string, { expand: ['payment_intent'] }); if (latestInvoice.payment_intent?.status === 'requires_action') { // Return client secret for 3D Secure authentication return { requiresAction: true, clientSecret: latestInvoice.payment_intent.client_secret }; } } return { requiresAction: false }; }
Best Practices for Prorated Subscription Changes
Based on years of implementing subscription billing systems, here are the essential best practices:
1. Always Test Proration Calculations in Stripe's Test Mode Create test scenarios for different billing cycles, plan combinations, and timing scenarios. Stripe's test mode provides identical proration logic to production.
2. Implement Idempotent Operations Use idempotency keys for all subscription modifications to prevent duplicate charges:
const idempotencyKey = `upgrade_${subscriptionId}_${Date.now()}`; await stripe.subscriptions.update(subscriptionId, { // ... update parameters }, { idempotencyKey });
3. Provide Clear Billing Previews Before making changes, show customers exactly what they'll be charged:
async function previewSubscriptionChange(subscriptionId: string, newPriceId: string) { const preview = await stripe.invoices.createPreview({ subscription: subscriptionId, subscription_items: [{ id: (await stripe.subscriptions.retrieve(subscriptionId)).items.data[0].id, price: newPriceId }] }); return { immediateCharge: preview.amount_due, nextBillingAmount: preview.lines.data[0].amount, prorationCredit: preview.lines.data.find(line => line.proration)?.amount || 0 }; }
4. Monitor and Alert on Failed Changes Set up monitoring for failed subscription changes and implement automatic retry logic with exponential backoff.
5. Document Your Proration Policy Clearly communicate to customers how upgrades and downgrades are handled, including timing and refund policies.
Conclusion
Implementing prorated subscription upgrades and downgrades correctly requires understanding both Stripe's proration mechanics and the business logic around plan changes. The key is handling edge cases gracefully—failed payments, race conditions, and complex billing scenarios that can break poorly implemented systems.
The code examples and patterns in this guide provide a foundation for robust subscription management, but remember that every SaaS business has unique requirements. Consider factors like your refund policy, feature access during payment failures, and customer communication when implementing these systems.
If you're building a complex subscription billing system with multiple plan types, usage-based components, or custom proration logic, consider working with specialists who have experience with these edge cases. Our Stripe subscription billing service includes implementation of advanced proration scenarios, webhook handling, and billing edge case management that can save weeks of development time and prevent costly billing errors.
Related Articles

