如何实现订阅变更
产品与定价的关系
在开始订阅升降级功能开发之前,我们需要明确一个重要的前提:Stripe 订阅变更计划是基于不同产品之间进行的,这意味着:
- 支持:Product A → Product B 的变更
- 不支持:Product A 的 Price 1 → Product A 的 Price 2 的变更
所以,建议按照功能层级划分产品,而不是将所有定价方案放在同一产品下,例如:
Product1:
├── Price: $10/month
└── Price: $100/year
Product2:
├── Price: $20/month
└── Price: $200/year
Product3:
├── Price: $50/month
└── Price: $500/year开启订阅变更的入口
进入 Stripe Customer Portal

在 Subscriptions 里面开启变更入口,依次设置订阅变更的选项



设置完成后,用户在 /dashboard/subscription 进入 portal 页面就能看到变更订阅的功能



用户变更计划的时候,Stripe 会自动计算差价。
看到这里你可能已经发现,如果你的产品有多种计划,其实订阅变更不是简单的升降级关系,而且定价越复杂,实现订阅变更的逻辑也会越复杂。
例如:当你的产品有多个月计划和多个年计划的时候,且均支持变更,那么你至少需要处理以下6种情况:
月计划变更
- 升级:月计划A → 月计划B(价格上涨)
- 降级:月计划B → 月计划A(价格下降)
年付计划变更
- 升级:年计划C → 年计划D(价格上涨)
- 降级:年计划D → 年计划C(价格下降)
付费周期变更
- 月转年:任意月付计划 → 对应年付计划
- 年转月:任意年付计划 → 对应月付计划
Nexty 提供这些订阅变更的判断与入口方法,但实际实现方法需要你根据本文档的思路去开发,当你理清思路后,AI 可以帮你完成大部分工作。
实现思路
确认处理入口
在网络上,你会看到通过 customer.subscription.updated 事件判断订阅升降级的方法,大致判断思路是这样:
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;
}
// 接着通过 currentPriceId 和 previousPriceId 获取两个计划进行对比价格,然后得出升级或降级的判断这种判断方式没有错,而且是最快捷的,但忽略了关键问题——在你的代码里,用户订阅后是在哪个事件处理用户权益升级的,如果用户权益升级不是在 customer.subscription.updated 处理,那么在这里判断用户订阅变更就是徒劳,因为其他事件仍然会按照原有逻辑处理用户权益。
以 Nexty.dev 模板为例,所有订阅付款的处理都在 invoice.paid 事件里处理,那么也必须在 invoice.paid 事件中处理订阅变更。
代码实现
提醒
请先查看
package.json文件的version字段,如果你使用的 Nexty 版本低于 v3.2.2,请先按照这个变更记录,修改查询planId的时机。此前的代码,将
subscription.metadata?.planId获取的planId作为主要方式,从数据库查询作为兜底方式,现在需要反过来,这样才能在订阅变更的时候获取到变更后的计划。
实现订阅变更判断的大致思路是:
- 从
invocie.paid事件推送的信息里,读取变更前后的price_id - 根据
price_id获取详细的计划信息,判断变更前后计划的价格、周期等,从中生成一个变更类型的标识 - 根据变更类型的标识和不同计划定义的权益,针对性处理用户权益
在现有的代码里,app/api/stripe/webhook/webhook-handlers.ts 里面的 handleInvoicePaid 方法有一个自动处理逻辑的入口,这就是我们要改造的地方了:
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 ---
}改成这样:
if (planId && userId && subscription) {
// --- [custom] Upgrade ---
const orderId = insertedOrder.id;
try {
// 检查是否是订阅变更(升级/降级)的invoice
const isSubscriptionUpdate = invoice.billing_reason === 'subscription_update' && invoice.lines.data.length === 2;
if (isSubscriptionUpdate) {
// 获取变更前后的 price_id
const currentPriceId = invoice.lines.data[0].pricing.price_details.price
const previousPriceId = invoice.lines.data[1].pricing.price_details.price
if (currentPriceId && previousPriceId) {
// 获取变更前后的定价信息和 changeType
const changeResult = await detectSubscriptionChange(currentPriceId, previousPriceId);
if (changeResult.changeType !== 'none') {
// 处理订阅变更,
// 由于变更场景复杂,模板无法覆盖全部场景,所以只提供入口并提供处理建议
// 入口方法 handleSubscriptionChange 也在 `app/api/stripe/webhook/subscription-change.ts` 文件里
await handleSubscriptionChange(subscription, changeResult);
return;
}
}
} else {
// 非订阅变更,不变
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 ---
}实现 detectSubscriptionChange,获取变更前后的订阅信息,并返回变更标识 changeType。这个方法已经实现,代码在 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
) {
// ...
}如何完成订阅变更完整逻辑
-
开发前请务必搞清楚 Stripe 开启订阅变更入口的各个设置项的作用,只有这样你才能想明白自己需要让哪些计划支持变更,这会影响实际实现的复杂度。
-
确定需求后,检查
app/api/stripe/webhook/subscription-change.ts提供的实现建议是否符合你的需求,如果有偏差,请修改文件里的注释 -
同时选中以下文件,并让 AI 根据
app/api/stripe/webhook/subscription-change.ts的注释完成逻辑开发
lib/db/schema.tsapp/api/stripe/webhook/
- 严谨的测试,你需要完整测试所有可能切换的场景,通过 Stripe 虚拟时钟功能可以推进时间,模拟订阅更新,然后通过以下页面排查是否有 bug:
/dashboard/credit-usage-example(测试页面,仅测试环境可以打开)/dashboard/credit-history/dashboard/subscription/dashboard/my-orders