Menu

如何实现年付订阅?

Nexty.dev 是一个功能完整的 SaaS 模板,默认内置了月订阅的逻辑,但在实际业务中,有用户希望能够提供年付订阅功能,同时保持按月分配 credits 的需求。这种模式既能为用户提供年付折扣的优惠,又能确保 credits 的合理分配和使用。

本文档将详细解释如何在现有的 Nexty.dev 模板基础上,实现年付订阅模式。

本文档实现的需求暂时只在subscription-yearly分支发布,未来将视 Nexty.dev 用户的需求决定是否合并到 main 分支。

现有架构回顾

数据库设计

当前系统包含以下核心表:

  1. pricing_plans - 定价计划表

    • benefits_jsonb - 权益配置(如 {"monthly_credits": 100})
    • recurring_interval - 计费周期(month/year)
    • payment_type - 支付类型(recurring/one_time)
  2. subscriptions - 订阅状态表

    • 跟踪Stripe订阅状态
    • 记录计费周期时间
  3. orders - 订单记录表

    • 记录所有支付事件
    • 支持多种订单类型
  4. usage - 使用量管理表

    • subscription_credits_balance - 订阅积分余额
    • one_time_credits_balance - 一次性积分余额
    • balance_jsonb - 扩展余额字段

支付逻辑

现有系统已支持:

  • 月订阅:每月自动扣费并重置 credits
  • 一次性购买:立即赋予 credits
  • Stripe webhook 处理各种支付事件

年付订阅设计方案

核心需求

  • 用户一次性支付全年费用
  • 系统在订阅开始时立即分配第一个月的 credits
  • 之后每个月自动分配下个月的 credits,直到12个月结束
  • 支持取消订阅时的 credits 回收

实现思路

根据 Nexty.dev 已有的功能和逻辑,可以梳理出以下实现步骤:

  • 创建年付定价卡片,在 benefits_jsonb 中记录年付订阅的必要信息
  • 扩展 usage 表,使其可以记录年付订阅的信息以及支持每个月发放 credits
  • 在不影响月订阅逻辑的情况下,在 Webhook 相关方法里实现年付订阅的权益升级、退款权益回收
  • 用户端可以看到年付订阅的信息
  • 能够顺利更新每个月的 credits。实现方案有两种:
    • 使用定时任务,每天找出需要重新发放的数据重置 credits
    • 在查询用户权益的时候,发现当前月度已经结束,则重置 credits,这种方式比较轻量,我们本文档采用这种方案来实现

实现步骤

步骤1:创建新定价

首先在 Stripe 创建新的产品定价,选择年付

add price

然后复制 Price ID

add price

启动你基于 Nexty.dev 开发的项目,进入 /dashboard/prices,开始创建新的定价卡片

add card

粘贴前面复制的 Price ID,然后获取定价信息,可以看到获取到的是年付定价

add card
add card

Benefits JSON 这一栏,要填写你设计的年付定价信息,这些信息将用于后面所有流程

add card

例如,我们的例子中输入的是:

{
  "total_months": 12,
  "credits_per_month": 500
}

步骤2:设计 usage 表的记录方式

针对不同的权益设计,我们可以选择拓展 usage 表字段或者利用现有的 balance_jsonb 字段来记录权益。

因为我们想要实现的是年付订阅按月发放 credits 的需求,所以可以利用已有的 subscription_credits_balance 字段来记录当前月度剩余的 credits,然后利用 balance_jsonb 记录年付信息,用于关联功能中判断这是年付订阅。

经过分析,可以设计如下的结构来记录年付信息:

{
  "yearly_allocation_details": {
    "remaining_months": 11,
    "next_credit_date": "2025-06-03T00:00:00.000Z",
    "monthly_credit_amount": 500,
    "last_allocated_month": "2025-06"
  }
}

它们的含义分别是:

  • yearly_allocation_details:这是选择单独使用一个 key 包裹年付信息,这样可以确保如果用户同时购买了多种定价计划,也不会出现互相干扰的情况
  • remaining_months:记录年付订阅剩余多少个月未发放 credits
  • next_credit_date:记录下一次发放 credits 的日期
  • monthly_credit_amount:记录每个月发放多少 credits,这一项来自定价卡片中我们设置的 credits_per_month
  • last_allocated_month:上一次成功分配 credits 的月份

为了让 yearly_allocation_details 能够正确运转,我们还需要设计一个年付订阅权益初始化的 RPC,和一个年付订阅按月更新权益的 RPC,分别命名为 initialize_or_reset_yearly_allocationallocate_specific_monthly_credit_for_year_plan

同时,为了让月订阅和年付订阅获取权益的逻辑同步,我将 upsert_and_set_subscription_creditsrevoke_credits 函数的定义也进行了优化。

函数定义太长,这里不粘贴出来,请到分支代码的 data/5、usage(yearly_credits_rpc).sql 文件查看,然后将文件内完整的 SQL 语句放在 Supabase 的 SQL Editor 执行一遍,执行后请在本地执行 supabase gen types typescript --project-id <your-project-id> --schema public > lib/supabase/types.ts 命令以更新类型定义。

步骤3:升级年付订阅权益

订阅支付的处理入口是 lib/stripe/webhook-handlers.tshandleInvoicePaid,定位到 upgradeSubscriptionCredits,这是自定义订阅用户权益升级的地方。

在较早的源码版本中,代码是这样的:

if (planId && userId) {
  // --- [custom] Upgrade the user's benefits ---
  upgradeSubscriptionCredits(userId, planId, invoiceId);
  // --- End: [custom] Upgrade the user's benefits ---
}

现在我们希望在用户端展示更多订阅信息,所以需要将 subscription 一起传给 upgradeSubscriptionCredits

lib/stripe/webhook-handlers.ts
if (planId && userId && subscription) {
  // --- [custom] Upgrade the user's benefits ---
  upgradeSubscriptionCredits(userId, planId, invoiceId, subscription);
  // --- End: [custom] Upgrade the user's benefits ---
}
lib/stripe/webhook-handlers.ts
export async function upgradeSubscriptionCredits(userId: string, planId: string, invoiceId: string, subscription: Stripe.Subscription) {
  const supabaseAdmin = ...
 
  try {
    // 需要获取 `recurring_interval` 用于判断是月订阅还是年付订阅
    const { data: planData, error: planError } = await supabaseAdmin
    .from('pricing_plans')
    // .select('benefits_jsonb') // remove
    .select('recurring_interval, benefits_jsonb') // add
    .eq('id', planId)
    .single();
 
    if (planError || !planData) {
      // ...
    } else {
      // 这里的逻辑需要改造,先判断是月订阅还是年付订阅,然后分别处理,这样对原有的逻辑影响最小
      const benefits = planData.benefits_jsonb as any;
      const recurringInterval = planData.recurring_interval;
 
      // 月订阅逻辑不变
      if (recurringInterval === 'month' && benefits?.monthly_credits) {
        const creditsToGrant = benefits.monthly_credits;
        const { error: usageError } = await supabaseAdmin.rpc('upsert_and_set_subscription_credits', {
          p_user_id: userId,
          p_credits_to_set: creditsToGrant
        });
 
        // ...
 
        return // 记住要 return,这样程序不需要做下一步判断
      }
 
      // 新增:年付订阅逻辑
      if (recurringInterval === 'year' && benefits?.total_months && benefits?.credits_per_month) {
          await supabaseAdmin.rpc('initialize_or_reset_yearly_allocation', {
            p_user_id: userId,
            p_total_months: benefits.total_months,
            p_credits_per_month: benefits.credits_per_month,
            p_subscription_start_date: new Date(subscription.start_date * 1000).toISOString()
          });
          return
        }
    }
  } catch (creditError) {
    // ...
  }
}

现在可以在你的页面测试付款流程,完成后分别检查 Supabase 数据库的 orderssubscriptionsusage 表新增的数据,确认与你的设计相符。

步骤4:更新用户权益展示

这一步需要先基于原有的月订阅权益获取逻辑,增加年付订阅的获取与更新逻辑。

为了能够在前端展示更详细的用户订阅信息,我们需要扩展 UserBenefits 的类型定义:

lib/stripe/actions.ts
export interface UserBenefits {
  activePlanId: string | null;
  subscriptionStatus: string | null;
  currentPeriodEnd: string | null; // add
  nextCreditDate: string | null; // add, monthly is null, yearly is the next credit date
  totalAvailableCredits: number;
  subscriptionCreditsBalance: number;
  oneTimeCreditsBalance: number;
}

同时定义默认返回的数据:

lib/stripe/actions.ts
const defaultUserBenefits: UserBenefits = {
  activePlanId: null,
  subscriptionStatus: null,
  currentPeriodEnd: null,
  nextCreditDate: null,
  totalAvailableCredits: 0,
  subscriptionCreditsBalance: 0,
  oneTimeCreditsBalance: 0,
};
lib/stripe/actions.ts
// `getUserBenefits` 方法代码太长了,文档仅展示思路,完整代码请查看源码
 
export async function getUserBenefits(userId: string): Promise<UserBenefits> {
  if (!userId) {
    return defaultUserBenefits;
  }
 
  // 用于用户获取自己的数据,受 RLS 策略限制
  const supabase = await createClient();
 
  // 使用管理员权限调用 RPC
  const supabaseAdminClient = createAdminClient(
    // ...  
  );
 
  try {
    // 1. 获取用户当前的 usage 数据,包括 balance_jsonb,其中可能包含年度订阅的分配详情
    let { data: usageData, error: usageError } = await supabase
      .from('usage')
      // .select('subscription_credits_balance, one_time_credits_balance') // remove
      .select('subscription_credits_balance, one_time_credits_balance, balance_jsonb') // add
      .eq('user_id', userId)
      .maybeSingle();
 
    // ...
 
    // --- Start of Yearly Subscription Catch-up Logic ---
    let currentBalanceJsonb = usageData.balance_jsonb as any;
    let currentYearlyDetails = currentBalanceJsonb?.yearly_allocation_details;
 
    // 2. 循环检查并分配
    while (
      currentYearlyDetails && ...
    ) {
      const creditsToAllocate = currentYearlyDetails.credits_per_month;
      const yearMonthToAllocate = new Date(currentYearlyDetails.next_credit_date).toISOString().slice(0, 7);
 
      const { error: rpcError } = await supabaseAdminClient.rpc('allocate_specific_monthly_credit_for_year_plan', {
        // ...
      });
 
      if (rpcError) {
        // ...
        break;
      } else {
        // Re-fetch usage data to get the latest state after allocation
        const { data: updatedUsageData, error: refetchError } = await supabase
          .from('usage')
          .select('subscription_credits_balance, one_time_credits_balance, balance_jsonb')
          .eq('user_id', userId)
          .maybeSingle();
 
        // ...
 
        usageData = updatedUsageData;
        currentBalanceJsonb = usageData.balance_jsonb as any;
        currentYearlyDetails = currentBalanceJsonb?.yearly_allocation_details;
 
        // ...
      }
    }
    // --- End of Yearly Subscription Catch-up Logic ---
 
    if (!usageData) {
      return defaultUserBenefits;
    }
 
    const subCredits = usageData?.subscription_credits_balance ?? 0;
    const oneTimeCredits = usageData?.one_time_credits_balance ?? 0;
    const totalCredits = subCredits + oneTimeCredits;
 
    const { data: subscription, error: subscriptionError } = await supabase
      .from('subscriptions')
      // .select('plan_id, status, current_period_end') // remove
      .select('plan_id, status, current_period_end, cancel_at_period_end') // add
      .eq('user_id', userId)
      // .in('status', ['active', 'trialing']) // remove
      .order('created_at', { ascending: false })
      .limit(1)
      // .maybeSingle(); // remove
      .single();
 
    // ...
 
    return {
      activePlanId,
      subscriptionStatus,
      currentPeriodEnd,
      nextCreditDate,
      totalAvailableCredits: totalCredits,
      subscriptionCreditsBalance: subCredits,
      oneTimeCreditsBalance: oneTimeCredits,
    };
 
  } catch (error) {
    // ...
    return defaultUserBenefits;
  }
 
 
}

提示

while(){} 代码块设计了订阅追赶的方案,即使用户中间几个月没有登录、没有使用会员权益,仍然可以在用户登录后追踪到准确的月度。场景举例:

  • 用户在6月成为年付会员,此时 usage 表记录的是下一次重新发放 credits 月度是7月
  • 但7月用户没有登录,直到8月重新登录使用产品
  • 此时出现因为 usage 表记录的是7月而导致判断错误,程序会自动追赶到8月准确的周期

接着更新前端用户权益展示。

用户权益的展示方案需要根据业务需求自定义,这里提供的展示方式仅作为示例:

components/layout/CurrentUserBenefitsDisplay.tsx
// ...
 
return (
  <div className="flex flex-col gap-2 text-sm">
    <div className="flex items-center gap-2">
      <Coins className="w-4 h-4 text-primary" />
      <span>Credits: {benefits.totalAvailableCredits}</span>
    </div>
 
    {benefits.nextCreditDate && (
      <div className="flex items-center gap-2">
        <Clock className="w-4 h-4 text-primary" />
        <span>
          Update Date: {dayjs(benefits.nextCreditDate).format("YYYY-MM-DD")}
        </span>
      </div>
    )}
 
    {benefits.currentPeriodEnd && (
      <div className="flex items-center gap-2">
        <Clock className="w-4 h-4 text-primary" />
        <span>
          Period End:{" "}
          {dayjs(benefits.currentPeriodEnd).format("YYYY-MM-DD")}
        </span>
      </div>
    )}
  </div>
)
 
// ...

/dashboard/subscription 页面,还需要优化一下订阅用户的判断:

// const isMember = benefits.subscriptionStatus === "active" || benefits.subscriptionStatus === "trialing"; // remove
const isMember = benefits.subscriptionStatus; // add

现在你可以在 Header 右上角、/dashboard/subscription 页面看到最新的订阅权益信息,也可以在 /dashboard/credit-usage-example 页面进行积分使用测试。

步骤5:退款回收 credits

订阅退款的处理入口是 lib/stripe/webhook-handlers.tsrevokeSubscriptionCredits

export async function revokeSubscriptionCredits(userId: string, planId: string, subscriptionId: string) {
  const supabaseAdmin = createAdminClient(
    // ...
  );
 
  // --- [custom] Revoke the user's subscription benefits ---
  try {
    const { data: planData, error: planError } = await supabaseAdmin
      .from('pricing_plans')
      // .select('benefits_jsonb') // remove
      .select('recurring_interval') // add
      .eq('id', planId)
      .single();
 
    if (planError || !planData) {
      return;
    }
 
    let subscriptionToRevoke = 0;
    const recurringInterval = planData.recurring_interval;
    let clearYearly = false; // 用于 RPC 判断是否需要清空年付订阅信息
    let clearMonthly = false; // 用于 RPC 判断是否需要清空月订阅信息
 
    const { data: usageData, error: usageError } = await supabaseAdmin
      .from('usage')
      .select('balance_jsonb')
      .eq('user_id', userId)
      .single();
 
    // ...
 
    // 分别处理月订阅与年付订阅的信息
    if (recurringInterval === 'year') {
      const yearlyDetails = usageData.balance_jsonb?.yearly_allocation_details;
      subscriptionToRevoke = yearlyDetails?.credits_per_month
      clearYearly = true;
    } else if (recurringInterval === 'month') {
      const monthlyDetails = usageData.balance_jsonb?.monthly_allocation_details;
      subscriptionToRevoke = monthlyDetails?.credits_per_month
      clearMonthly = true;
    }
 
    if (subscriptionToRevoke) {
      const { data: revokeResult, error: revokeError } = await supabaseAdmin.rpc('revoke_credits', {
        // ...
      });
    }
  } catch (error) {
    console.error(`Error during revokeSubscriptionCredits for user ${userId}, subscription ${subscriptionId}:`, error);
  }
  // --- End: [custom] Revoke the user's subscription benefits ---
}

到这里就完成了年付订阅按月发放 credits 的全部开发工作了。

结语

Nexty.dev 定价与支付功能的完成度、安全性和灵活性都领先其他 Next.js SaaS 模板。不过,由于不同产品的订阅权益设计差异较大,我们无法将所有个性化需求都预置到模板中。

Nexty.dev 模板希望通过可视化管理的方式(管理后台创建编辑定价)、完整的支付流程、(Webhook 事件处理)、可控的自定义步骤(使用[custom]标记的代码块)和文档讲解实现思路帮助模板使用者更容易实现自己所需的支付功能。