Menu

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/year

Enabling the Subscription Change Entry Point

Navigate to Stripe Customer Portal

Stripe Customer Portal

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

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

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

Stripe Customer portal subscription
Stripe Customer portal subscription
Stripe Customer portal 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 downgrade

This 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 version field in the package.json file. If you're using a Nexty version lower than v3.2.2, please first follow this change record to modify the timing of querying planId.

Previous code used subscription.metadata?.planId as the primary method for obtaining planId, 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_id before and after the change from the invoice.paid event 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:

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 ---
    }

Change it to this:

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

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

  1. 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.

  2. After determining requirements, check whether the implementation suggestions provided in app/api/stripe/webhook/subscription-change.ts meet your needs. If there are discrepancies, please modify the comments in the file.

  3. 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.ts
  • app/api/stripe/webhook/
  1. 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