Menu

如何实现订阅变更

产品与定价的关系

在开始订阅升降级功能开发之前,我们需要明确一个重要的前提: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

Stripe Customer Portal

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

Stripe Customer portal subscription
Stripe Customer portal subscription
Stripe Customer portal subscription

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

Stripe Customer portal subscription
Stripe Customer portal subscription
Stripe Customer portal subscription

用户变更计划的时候,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 方法有一个自动处理逻辑的入口,这就是我们要改造的地方了:

app/api/stripe/webhook/webhook-handlers.ts
    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 ---
    }

改成这样:

app/api/stripe/webhook/webhook-handlers.ts
    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

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
) {
  // ...
}

如何完成订阅变更完整逻辑

  1. 开发前请务必搞清楚 Stripe 开启订阅变更入口的各个设置项的作用,只有这样你才能想明白自己需要让哪些计划支持变更,这会影响实际实现的复杂度。

  2. 确定需求后,检查 app/api/stripe/webhook/subscription-change.ts 提供的实现建议是否符合你的需求,如果有偏差,请修改文件里的注释

  3. 同时选中以下文件,并让 AI 根据 app/api/stripe/webhook/subscription-change.ts 的注释完成逻辑开发

  • lib/db/schema.ts
  • app/api/stripe/webhook/
  1. 严谨的测试,你需要完整测试所有可能切换的场景,通过 Stripe 虚拟时钟功能可以推进时间,模拟订阅更新,然后通过以下页面排查是否有 bug:
  • /dashboard/credit-usage-example(测试页面,仅测试环境可以打开)
  • /dashboard/credit-history
  • /dashboard/subscription
  • /dashboard/my-orders