Menu

年間サブスクリプションの実装方法とは?

Nexty.devは機能完備のSaaSテンプレートで、デフォルトで月次サブスクリプションロジックが含まれています。しかし、実際のビジネスシナリオでは、ユーザーは月次クレジット配分を維持しながら年間サブスクリプション機能を提供したいことがよくあります。このモデルは、ユーザーに年間割引を提供しながら、合理的なクレジット配布と使用を確保します。

この文書では、既存のNexty.devテンプレートに基づいて年間サブスクリプションモードを実装する方法を詳しく説明します。

この文書で説明されている実装は、現在subscription-yearly branchでのみ利用可能です。メインブランチにマージされるかどうかは、今後のユーザー需要によります。

現在のアーキテクチャレビュー

データベース設計

現在のシステムには以下のコアテーブルが含まれています:

  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 - 拡張残高フィールド

支払いロジック

既存システムは以下をすでにサポートしています:

  • 月次サブスクリプション:月次自動請求とクレジットリセット
  • 一回限り購入:即座のクレジット配分
  • さまざまな支払いイベントのStripe webhook処理

年間サブスクリプション設計

コア要件

  • ユーザーは年間全額を一回の取引で支払い
  • サブスクリプション開始時に最初の月のクレジットを即座に配分
  • 12か月完了まで毎月次の月のクレジットを自動配分
  • サブスクリプションキャンセル時のクレジット回復をサポート

実装アプローチ

Nexty.devの既存機能とロジックに基づいて、以下の実装ステップを概要説明できます:

  • 年間料金カードを作成し、benefits_jsonbに必要な年間サブスクリプション情報を記録
  • usageテーブルを拡張して年間サブスクリプション情報を記録し、月次クレジット配分をサポート
  • 月次サブスクリプションロジックに影響を与えることなく、webhook関連メソッドで年間サブスクリプション特典アップグレードと返金特典回復を実装
  • ユーザーが年間サブスクリプション情報を表示できるようにする
  • 月次クレジットの更新を成功させる。実装アプローチは2つあります:
    • スケジュールタスクを使用して、再配分が必要なデータを毎日検索してクレジットをリセット
    • ユーザー特典を照会する際、現在の月が終了している場合はクレジットをリセット。このアプローチはより軽量で、この文書ではこの方法を使用します

実装ステップ

ステップ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フィールドを使用して特典を記録することができます。

月次クレジット配分による年間サブスクリプションを実装したいため、既存のsubscription_credits_balanceフィールドを使用して現在月の残りクレジットを記録し、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:別のキーを使用して年間情報をラップし、ユーザーが複数の料金プランを同時に購入した場合の干渉を防ぐ
  • remaining_months:未配分の年間サブスクリプション月数を記録
  • next_credit_date:次のクレジット配分日を記録
  • monthly_credit_amount:月次クレジット配分量を記録、料金カードのcredits_per_monthから取得
  • last_allocated_month:クレジットが正常に配分された最後の月

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ファイルを確認し、SupabaseのSQL Editorで完全なSQLステートメントを実行してください。実行後、ローカルで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] ユーザーの特典をアップグレード ---
  upgradeSubscriptionCredits(userId, planId, invoiceId);
  // --- 終了:[custom] ユーザーの特典をアップグレード ---
}

今度はユーザー側でより多くのサブスクリプション情報を表示したいので、subscriptionupgradeSubscriptionCreditsに渡す必要があります:

lib/stripe/webhook-handlers.ts
if (planId && userId && subscription) {
  // --- [custom] ユーザーの特典をアップグレード ---
  upgradeSubscriptionCredits(userId, planId, invoiceId, subscription);
  // --- 終了:[custom] ユーザーの特典をアップグレード ---
}
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') // 削除
    .select('recurring_interval, benefits_jsonb') // 追加
    .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; // 追加
  nextCreditDate: string | null; // 追加、月次はnull、年間は次のクレジット日
  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. ユーザーの現在の使用量データを取得、balance_jsonbを含む(年間サブスクリプション配分詳細が含まれる可能性がある)
    let { data: usageData, error: usageError } = await supabase
      .from('usage')
      // .select('subscription_credits_balance, one_time_credits_balance') // 削除
      .select('subscription_credits_balance, one_time_credits_balance, balance_jsonb') // 追加
      .eq('user_id', userId)
      .maybeSingle();
 
    // ...
 
    // --- 年間サブスクリプションキャッチアップロジック開始 ---
    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 {
        // 配分後の最新状態を取得するため使用量データを再フェッチ
        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;
 
        // ...
      }
    }
    // --- 年間サブスクリプションキャッチアップロジック終了 ---
 
    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') // 削除
      .select('plan_id, status, current_period_end, cancel_at_period_end') // 追加
      .eq('user_id', userId)
      // .in('status', ['active', 'trialing']) // 削除
      .order('created_at', { ascending: false })
      .limit(1)
      // .maybeSingle(); // 削除
      .single();
 
    // ...
 
    return {
      activePlanId,
      subscriptionStatus,
      currentPeriodEnd,
      nextCreditDate,
      totalAvailableCredits: totalCredits,
      subscriptionCreditsBalance: subCredits,
      oneTimeCreditsBalance: oneTimeCredits,
    };
 
  } catch (error) {
    // ...
    return defaultUserBenefits;
  }
 
 
}

注目すべき点

while(){}コードブロックはサブスクリプションキャッチアップソリューションを設計します。ユーザーが数か月間ログインしなかったり、メンバーシップ特典を使用しなかったりしても、ログイン後に正確な月次期間を追跡できます。例のシナリオ:

  • ユーザーが6月に年間メンバーになり、この時点でusageテーブルは次のクレジット再配分月が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>クレジット:{benefits.totalAvailableCredits}</span>
    </div>
 
    {benefits.nextCreditDate && (
      <div className="flex items-center gap-2">
        <Clock className="w-4 h-4 text-primary" />
        <span>
          更新日:{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>
          期間終了:{" "}
          {dayjs(benefits.currentPeriodEnd).format("YYYY-MM-DD")}
        </span>
      </div>
    )}
  </div>
)
 
// ...

/dashboard/subscriptionページでも、サブスクリプションユーザー判定を最適化する必要があります:

// const isMember = benefits.subscriptionStatus === "active" || benefits.subscriptionStatus === "trialing"; // 削除
const isMember = benefits.subscriptionStatus; // 追加

これで、ヘッダーの右上角と/dashboard/subscriptionページで最新のサブスクリプション特典情報を確認でき、/dashboard/credit-usage-exampleページでクレジット使用もテストできます。

ステップ5:返金クレジット回復

サブスクリプション返金処理のエントリーポイントはlib/stripe/webhook-handlers.tsrevokeSubscriptionCreditsです。

export async function revokeSubscriptionCredits(userId: string, planId: string, subscriptionId: string) {
  const supabaseAdmin = createAdminClient(
    // ...
  );
 
  // --- [custom] ユーザーのサブスクリプション特典を取り消し ---
  try {
    const { data: planData, error: planError } = await supabaseAdmin
      .from('pricing_plans')
      // .select('benefits_jsonb') // 削除
      .select('recurring_interval') // 追加
      .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);
  }
  // --- 終了:[custom] ユーザーのサブスクリプション特典を取り消し ---
}

これで月次クレジット配分による年間サブスクリプションのすべての開発作業が完了しました。

まとめ

Nexty.devの料金設定と支払い機能は、完全性、セキュリティ、柔軟性において他のNext.js SaaSテンプレートをリードしています。しかし、異なる製品間でのサブスクリプション特典設計の大きな違いにより、すべての個人化要件をテンプレートに事前インストールすることはできません。

Nexty.devテンプレートは、視覚的管理(料金の作成と編集のための管理バックエンド)、完全な支払いフロー、(webhookイベント処理)、制御可能なカスタマイズステップ([custom]でマークされたコードブロック)、実装アプローチを説明する文書を通じて、テンプレートユーザーが必要な支払い機能をより簡単に実装できるよう支援することを目的としています。