How to Implement Subscription Changes
Relationship Between Products and Pricing
Before starting development of subscription upgrade/downgrade functionality, we need to clarify an important prerequisite: Stripe subscription plan changes are based on switching between different products, which means:
- Supported: Product A → Product B changes
- Not supported: Product A's Price 1 → Product A's Price 2 changes
Therefore, it's recommended to organize products by feature tiers rather than placing all pricing plans under the same product, for example:
Product1:
├── Price: $10/month
└── Price: $100/year
Product2:
├── Price: $20/month
└── Price: $200/year
Product3:
├── Price: $50/month
└── Price: $500/yearEnabling the Subscription Change Entry Point
Navigate to Stripe Customer Portal

Enable the change entry point in Subscriptions, then configure the subscription change options in sequence



After configuration is complete, users can access the subscription change functionality by entering the portal page at /dashboard/subscription



When users change plans, Stripe automatically calculates the price difference.
At this point, you may have realized that if your product has multiple plans, subscription changes aren't simply upgrade/downgrade relationships, and the more complex your pricing structure, the more complex the logic for implementing subscription changes becomes.
For example: When your product has multiple monthly plans and multiple yearly plans, all supporting changes, you'll need to handle at least these 6 scenarios:
Monthly Plan Changes
- Upgrade: Monthly Plan A → Monthly Plan B (price increase)
- Downgrade: Monthly Plan B → Monthly Plan A (price decrease)
Yearly Plan Changes
- Upgrade: Yearly Plan C → Yearly Plan D (price increase)
- Downgrade: Yearly Plan D → Yearly Plan C (price decrease)
Billing Cycle Changes
- Monthly to Yearly: Any monthly plan → Corresponding yearly plan
- Yearly to Monthly: Any yearly plan → Corresponding monthly plan
Nexty provides methods for subscription change detection and entry points, but the actual implementation needs to be developed according to the approach outlined in this documentation. Once you understand the logic, AI can help you complete most of the work.
Implementation Approach
Confirm the Processing Entry Point
Online, you'll find methods that use the customer.subscription.updated event to determine subscription upgrades/downgrades, with logic roughly like this:
case 'customer.subscription.updated':
const subscription = event.data.object as Stripe.Subscription;
const previousAttributes = (event as any).data?.previous_attributes;
const currentPriceId = subscription.items.data[0]?.price.id;
const previousItems = previousAttributes.items as Stripe.ApiList<Stripe.SubscriptionItem>;
const previousPriceId = previousItems?.data?.[0]?.price?.id;
if (!currentPriceId || !previousPriceId || currentPriceId === previousPriceId) {
return defaultResult;
}
// Then compare the two plans by obtaining them through currentPriceId and previousPriceId, and determine upgrade or downgradeThis approach isn't wrong and is the quickest, but it overlooks a key issue—in your code, which event handles user benefit upgrades after subscription? If user benefit upgrades aren't handled in customer.subscription.updated, then determining user subscription changes here is futile, because other events will still process user benefits according to the original logic.
Using the Nexty.dev boilerplate as an example, all subscription payment processing is handled in the invoice.paid event, so subscription changes must also be handled in the invoice.paid event.
Code Implementation
Note
Please first check the
versionfield in thepackage.jsonfile. If you're using a Nexty version lower than v3.2.2, please first follow this change record to modify the timing of queryingplanId.Previous code used
subscription.metadata?.planIdas the primary method for obtainingplanId, with database query as fallback. Now this needs to be reversed so that the changed plan can be obtained during subscription changes.
The general approach for implementing subscription change detection is:
- Read the
price_idbefore and after the change from theinvoice.paidevent information - Obtain detailed plan information based on
price_id, determine the price, cycle, etc. of plans before and after the change, and generate a change type identifier - Handle user benefits specifically based on the change type identifier and the benefits defined by different plans
In the existing code, the handleInvoicePaid method in app/api/stripe/webhook/webhook-handlers.ts has an automatic processing logic entry point—this is what we need to refactor:
if (planId && userId && subscription) {
// --- [custom] Upgrade ---
const orderId = insertedOrder.id;
try {
await upgradeSubscriptionCredits(userId, planId, orderId, subscription);
} catch (error) {
console.error(`CRITICAL: Failed to upgrade subscription credits for user ${userId}, order ${orderId}:`, error);
await sendCreditUpgradeFailedEmail({ userId, orderId, planId, error });
throw error;
}
// --- End: [custom] Upgrade ---
}Change it to this:
if (planId && userId && subscription) {
// --- [custom] Upgrade ---
const orderId = insertedOrder.id;
try {
// Check if this is a subscription change (upgrade/downgrade) invoice
const isSubscriptionUpdate = invoice.billing_reason === 'subscription_update' && invoice.lines.data.length === 2;
if (isSubscriptionUpdate) {
// Get price_id before and after the change
const currentPriceId = invoice.lines.data[0].pricing.price_details.price
const previousPriceId = invoice.lines.data[1].pricing.price_details.price
if (currentPriceId && previousPriceId) {
// Get pricing information and changeType before and after the change
const changeResult = await detectSubscriptionChange(currentPriceId, previousPriceId);
if (changeResult.changeType !== 'none') {
// Handle subscription change,
// Due to the complexity of change scenarios, the boilerplate cannot cover all scenarios, so only the entry point and handling suggestions are provided
// The entry method handleSubscriptionChange is also in the `app/api/stripe/webhook/subscription-change.ts` file
await handleSubscriptionChange(subscription, changeResult);
return;
}
}
} else {
// Not a subscription change, no change
await upgradeSubscriptionCredits(userId, planId, orderId, subscription);
}
} catch (error) {
console.error(`CRITICAL: Failed to upgrade subscription credits for user ${userId}, order ${orderId}:`, error);
await sendCreditUpgradeFailedEmail({ userId, orderId, planId, error });
throw error;
}
// --- End: [custom] Upgrade ---
}Implement detectSubscriptionChange to obtain subscription information before and after the change, and return the change identifier changeType. This method is already implemented, with code in app/api/stripe/webhook/subscription-change.ts
// import ...
/**
* Subscription change type details
* 订阅变更类型详情
* サブスクリプション変更タイプの詳細
*/
export type SubscriptionChangeType =
| 'monthly_to_monthly_upgrade'
| 'monthly_to_monthly_downgrade'
| 'yearly_to_yearly_upgrade'
| 'yearly_to_yearly_downgrade'
| 'monthly_to_yearly_change'
| 'yearly_to_monthly_change'
| 'none'; // no change
/**
* Subscription change detection result
* 订阅变更检测结果
* サブスクリプション変更検出結果
*/
export interface SubscriptionChangeResult {
changeType: SubscriptionChangeType;
previousPriceId?: string;
currentPriceId?: string;
previousPlanId?: string;
currentPlanId?: string;
previousInterval?: string;
currentInterval?: string;
previousPrice?: string;
currentPrice?: string;
}
const getChangeType = (prevInterval: string, currInterval: string, prevAmount: number, currAmount: number) => {
if (prevInterval !== currInterval) {
return `${prevInterval}_to_${currInterval}_change`
}
if (prevAmount === currAmount) return 'none';
const direction = currAmount > prevAmount ? 'upgrade' : 'downgrade';
return `${currInterval}_to_${currInterval}_${direction}`
};
/**
* Detects if a subscription update is an upgrade, downgrade, or interval change
* 检测订阅更新是升级、降级还是周期变更
* サブスクリプションの更新がアップグレード、ダウングレード、または期間変更かを検出
*/
export async function detectSubscriptionChange(
currentPriceId: string,
previousPriceId: string
) {
const defaultResult: SubscriptionChangeResult = {
changeType: 'none',
};
if (!currentPriceId || !previousPriceId) {
return defaultResult;
}
// Fetch plan information from database to compare
const [currentPlanResults, previousPlanResults] = await Promise.all([
db
.select({
id: pricingPlansSchema.id,
price: pricingPlansSchema.price,
recurringInterval: pricingPlansSchema.recurringInterval,
benefitsJsonb: pricingPlansSchema.benefitsJsonb,
})
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.stripePriceId, currentPriceId))
.limit(1),
db
.select({
id: pricingPlansSchema.id,
price: pricingPlansSchema.price,
recurringInterval: pricingPlansSchema.recurringInterval,
benefitsJsonb: pricingPlansSchema.benefitsJsonb,
})
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.stripePriceId, previousPriceId))
.limit(1),
]);
const currentPlan = currentPlanResults[0];
const previousPlan = previousPlanResults[0];
if (!currentPlan || !previousPlan) {
console.warn(`Could not find plan data for price comparison. Current: ${currentPriceId}, Previous: ${previousPriceId}`);
return defaultResult;
}
const currentInterval = currentPlan.recurringInterval?.toLowerCase();
const previousInterval = previousPlan.recurringInterval?.toLowerCase();
const currentAmount = parseFloat(currentPlan.price || '0');
const previousAmount = parseFloat(previousPlan.price || '0');
// Determine change type based on interval and price
const changeType = getChangeType(previousInterval as string, currentInterval as string, previousAmount, currentAmount);
return {
changeType: changeType || 'none',
previousPriceId,
currentPriceId,
previousPlanId: previousPlan.id,
currentPlanId: currentPlan.id,
previousInterval: previousInterval || undefined,
currentInterval: currentInterval || undefined,
previousPrice: previousPlan.price || undefined,
currentPrice: currentPlan.price || undefined,
};
}
/**
* Main router function for handling subscription changes
* 订阅变更的主路由函数
* サブスクリプション変更のメインルーター関数
*/
export async function handleSubscriptionChange(
subscription: Stripe.Subscription,
changeResult: SubscriptionChangeResult
) {
// ...
}How to Complete the Full Subscription Change Logic
-
Before development, make sure you fully understand the purpose of each setting option in Stripe's subscription change entry point. Only then can you determine which plans need to support changes, which will affect the complexity of the actual implementation.
-
After determining requirements, check whether the implementation suggestions provided in
app/api/stripe/webhook/subscription-change.tsmeet your needs. If there are discrepancies, please modify the comments in the file. -
Select the following files simultaneously and have AI complete the logic development based on the comments in
app/api/stripe/webhook/subscription-change.ts:
lib/db/schema.tsapp/api/stripe/webhook/
- Rigorous testing. You need to thoroughly test all possible switching scenarios. Using Stripe's test clock feature, you can advance time to simulate subscription updates, then troubleshoot for bugs through the following pages:
/dashboard/credit-usage-example(test page, only accessible in test environment)/dashboard/credit-history/dashboard/subscription/dashboard/my-orders