Menu

クレジットシステム

クレジットシステムは、NEXTY.DEVの決済システムのコア機能の1つです。ユーザーがプランを購入することで獲得するクレジットを管理し、クレジットの使用を追跡します。このドキュメントは、クレジットシステムの設計と使用方法を詳しく説明します。

クレジットシステム概要

クレジットタイプ

システムは2つのタイプのクレジットをサポートしています:

  1. 一回限りのクレジット
  • 一回限りの決済を通じて獲得
  • 期限切れなし
  • 使用後に減少し、自動的に補充されない
  1. サブスクリプションクレジット
  • サブスクリプションプランを通じて獲得
  • 月次または年次で配分
  • 定期的にリセットまたは補充される

クレジット残高の保存

クレジット残高はusageテーブルに保存されます:

{
  userId: string;                    // ユーザーID(一意)
  subscriptionCreditsBalance: number; // サブスクリプションクレジット残高
  oneTimeCreditsBalance: number;     // 一回限りのクレジット残高
  balanceJsonb: {                    // 残高詳細(JSONB)
    monthlyAllocationDetails?: {     // 月額サブスクリプション詳細
      monthlyCredits: number;
      relatedOrderId: string;
    };
    yearlyAllocationDetails?: {      // 年額サブスクリプション詳細
      remainingMonths: number;
      nextCreditDate: string;
      monthlyCredits: number;
      lastAllocatedMonth: string;
      relatedOrderId: string;
    };
  };
}

クレジットログ

すべてのクレジット変更はcredit_logsテーブルに記録されます:

{
  userId: string;
  amount: number;                   // 変更額(増加は正、減少は負)
  oneTimeBalanceAfter: number;      // 変更後の一回限りクレジット残高
  subscriptionBalanceAfter: number; // 変更後のサブスクリプションクレジット残高
  type: string;                     // 変更タイプ
  notes: string;                    // 備考
  relatedOrderId?: string;          // 関連注文ID
  createdAt: Date;                  // 作成時刻
}

クレジット付与

一回限りのクレジット付与

ユーザーが一回限りの決済プランを購入すると、システムは一回限りのクレジットを付与します:

lib/payments/credit-manager.ts
export async function upgradeOneTimeCredits(
  userId: string,
  planId: string,
  orderId: string
) {
  // 1. プランの特典設定を取得
  const plan = await db
    .select({ benefitsJsonb: pricingPlansSchema.benefitsJsonb })
    .from(pricingPlansSchema)
    .where(eq(pricingPlansSchema.id, planId))
    .limit(1);
 
  const creditsToGrant = plan.benefitsJsonb?.oneTimeCredits || 0;
 
  if (creditsToGrant > 0) {
    // 2. トランザクションを使用してクレジット残高を更新
    await db.transaction(async (tx) => {
      // クレジット残高を更新または挿入
      const updatedUsage = await tx
        .insert(usageSchema)
        .values({
          userId,
          oneTimeCreditsBalance: creditsToGrant,
        })
        .onConflictDoUpdate({
          target: usageSchema.userId,
          set: {
            oneTimeCreditsBalance: sql`${usageSchema.oneTimeCreditsBalance} + ${creditsToGrant}`,
          },
        })
        .returning({
          oneTimeBalanceAfter: usageSchema.oneTimeCreditsBalance,
          subscriptionBalanceAfter: usageSchema.subscriptionCreditsBalance,
        });
 
      // 3. クレジットログを記録
      await tx.insert(creditLogsSchema).values({
        userId,
        amount: creditsToGrant,
        oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
        subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
        type: 'one_time_purchase',
        notes: '一回限りのクレジット購入',
        relatedOrderId: orderId,
      });
    });
  }
}

サブスクリプションクレジット付与

月額サブスクリプション

月額サブスクリプションは、各更新時にクレジットをリセットします:

lib/payments/credit-manager.ts
export async function upgradeSubscriptionCredits(
  userId: string,
  planId: string,
  orderId: string,
  currentPeriodStart: number
) {
  const plan = await db
    .select({
      recurringInterval: pricingPlansSchema.recurringInterval,
      benefitsJsonb: pricingPlansSchema.benefitsJsonb
    })
    .from(pricingPlansSchema)
    .where(eq(pricingPlansSchema.id, planId))
    .limit(1);
 
  const monthlyCredits = plan.benefitsJsonb?.monthlyCredits || 0;
 
  if (isMonthlyInterval(plan.recurringInterval) && monthlyCredits > 0) {
    await db.transaction(async (tx) => {
      // サブスクリプションクレジット残高を更新(月額クレジットにリセット)
      const updatedUsage = await tx
        .insert(usageSchema)
        .values({
          userId,
          subscriptionCreditsBalance: monthlyCredits,
          balanceJsonb: {
            monthlyAllocationDetails: {
              monthlyCredits,
              relatedOrderId: orderId,
            }
          },
        })
        .onConflictDoUpdate({
          target: usageSchema.userId,
          set: {
            subscriptionCreditsBalance: monthlyCredits,
            balanceJsonb: sql`coalesce(${usageSchema.balanceJsonb}, '{}'::jsonb) - 'monthlyAllocationDetails' || ${JSON.stringify(monthlyDetails)}::jsonb`,
          },
        })
        .returning({
          oneTimeBalanceAfter: usageSchema.oneTimeCreditsBalance,
          subscriptionBalanceAfter: usageSchema.subscriptionCreditsBalance,
        });
 
      // クレジットログを記録
      await tx.insert(creditLogsSchema).values({
        userId,
        amount: monthlyCredits,
        oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
        subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
        type: 'subscription_grant',
        notes: 'サブスクリプションクレジットを付与/リセット',
        relatedOrderId: orderId,
      });
    });
  }
}

年額サブスクリプション

年額サブスクリプションは、月次配分アプローチを使用します:

lib/payments/credit-manager.ts
if (isYearlyInterval(plan.recurringInterval) && benefits?.totalMonths && benefits?.monthlyCredits) {
  await db.transaction(async (tx) => {
    const startDate = new Date(currentPeriodStart);
    const nextCreditDate = new Date(
      startDate.getFullYear(),
      startDate.getMonth() + 1,
      startDate.getDate()
    );
 
    const yearlyDetails = {
      yearlyAllocationDetails: {
        remainingMonths: benefits.totalMonths - 1,
        nextCreditDate: nextCreditDate.toISOString(),
        monthlyCredits: benefits.monthlyCredits,
        lastAllocatedMonth: `${startDate.getFullYear()}-${(startDate.getMonth() + 1).toString().padStart(2, '0')}`,
        relatedOrderId: orderId,
      }
    };
 
    // 年額サブスクリプションを初期化し、最初の月のクレジットをすぐに配分
    const updatedUsage = await tx
      .insert(usageSchema)
      .values({
        userId,
        subscriptionCreditsBalance: benefits.monthlyCredits,
        balanceJsonb: yearlyDetails,
      })
      .onConflictDoUpdate({
        target: usageSchema.userId,
        set: {
          subscriptionCreditsBalance: benefits.monthlyCredits,
          balanceJsonb: sql`coalesce(${usageSchema.balanceJsonb}, '{}'::jsonb) - 'yearlyAllocationDetails' || ${JSON.stringify(yearlyDetails)}::jsonb`,
        }
      })
      .returning({
        oneTimeBalanceAfter: usageSchema.oneTimeCreditsBalance,
        subscriptionBalanceAfter: usageSchema.subscriptionCreditsBalance,
      });
 
    // クレジットログを記録
    await tx.insert(creditLogsSchema).values({
      userId,
      amount: benefits.monthlyCredits,
      oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
      subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
      type: 'subscription_grant',
      notes: '年額プランの初期クレジットを付与',
      relatedOrderId: orderId,
    });
  });
}

年額サブスクリプションの月次配分

年額サブスクリプションは「月次配分」戦略を使用します。システムは毎月自動的にクレジットを配分します:

actions/usage/benefits.ts
async function processYearlySubscriptionCatchUp(userId: string) {
  let shouldContinue = true;
 
  while (shouldContinue) {
    shouldContinue = await db.transaction(async (tx) => {
      const usage = await tx
        .select()
        .from(usageSchema)
        .where(eq(usageSchema.userId, userId))
        .for('update');
 
      const yearlyDetails = usage[0]?.balanceJsonb?.yearlyAllocationDetails;
 
      // クレジットの配分が必要か確認
      if (
        !yearlyDetails ||
        yearlyDetails.remainingMonths <= 0 ||
        !yearlyDetails.nextCreditDate ||
        new Date() < new Date(yearlyDetails.nextCreditDate)
      ) {
        return false; // 配分不要
      }
 
      // 今月のクレジットを配分
      const creditsToAllocate = yearlyDetails.monthlyCredits;
      const newRemainingMonths = yearlyDetails.remainingMonths - 1;
      const nextCreditDate = new Date(yearlyDetails.nextCreditDate);
      nextCreditDate.setMonth(nextCreditDate.getMonth() + 1);
 
      // クレジット残高を更新
      await tx.update(usageSchema)
        .set({
          subscriptionCreditsBalance: creditsToAllocate,
          balanceJsonb: {
            ...usage[0].balanceJsonb,
            yearlyAllocationDetails: {
              ...yearlyDetails,
              remainingMonths: newRemainingMonths,
              nextCreditDate: nextCreditDate.toISOString(),
              lastAllocatedMonth: new Date().toISOString().slice(0, 7),
            }
          },
        })
        .where(eq(usageSchema.userId, userId));
 
      // クレジットログを記録
      await tx.insert(creditLogsSchema).values({
        userId,
        amount: creditsToAllocate,
        type: 'subscription_grant',
        notes: '年額サブスクリプションの月次クレジットを配分',
        relatedOrderId: yearlyDetails.relatedOrderId,
        createdAt: new Date(yearlyDetails.nextCreditDate), // 配分日を使用
      });
 
      return newRemainingMonths > 0; // 残りの月がある場合はループを継続
    });
  }
}

年額サブスクリプションの月次クレジットリセットは、スケジュールタスクを使用しません。代わりに、ユーザーの特典をクエリするたびに自動的にチェックして更新するように設計されています。このアプローチは、スケジュールタスク処理と比較して、データベースリクエストを大幅に削減し、パフォーマンスを向上させます。

クレジットの使用

クレジットの控除

ユーザーが機能を使用してクレジットを消費する場合、deductCredits関数を呼び出します:

actions/usage/deduct.ts
export async function deductCredits(
  amountToDeduct: number,
  notes: string
): Promise<ActionResult<DeductCreditsData | null>> {
  const session = await getSession();
  const user = session?.user;
  if (!user) return actionResponse.unauthorized();
 
  if (amountToDeduct <= 0) {
    return actionResponse.badRequest('控除額は正の値である必要があります。');
  }
 
  try {
    await db.transaction(async (tx) => {
      // ユーザーのクレジット記録をロック
      const usage = await tx
        .select({
          oneTimeCreditsBalance: usageSchema.oneTimeCreditsBalance,
          subscriptionCreditsBalance: usageSchema.subscriptionCreditsBalance,
        })
        .from(usageSchema)
        .where(eq(usageSchema.userId, user.id))
        .for('update');
 
      if (!usage[0]) {
        throw new Error('INSUFFICIENT_CREDITS');
      }
 
      const totalCredits = usage[0].oneTimeCreditsBalance + usage[0].subscriptionCreditsBalance;
      if (totalCredits < amountToDeduct) {
        throw new Error('INSUFFICIENT_CREDITS');
      }
 
      // サブスクリプションクレジットを優先的に控除し、次に一回限りのクレジットを控除
      const deductedFromSub = Math.min(usage[0].subscriptionCreditsBalance, amountToDeduct);
      const deductedFromOneTime = amountToDeduct - deductedFromSub;
 
      const newSubBalance = usage[0].subscriptionCreditsBalance - deductedFromSub;
      const newOneTimeBalance = usage[0].oneTimeCreditsBalance - deductedFromOneTime;
 
      // クレジット残高を更新
      await tx.update(usageSchema)
        .set({
          subscriptionCreditsBalance: newSubBalance,
          oneTimeCreditsBalance: newOneTimeBalance,
        })
        .where(eq(usageSchema.userId, user.id));
 
      // クレジットログを記録
      await tx.insert(creditLogsSchema)
        .values({
          userId: user.id,
          amount: -amountToDeduct,
          oneTimeBalanceAfter: newOneTimeBalance,
          subscriptionBalanceAfter: newSubBalance,
          type: 'feature_usage',
          notes: notes,
        });
    });
 
    // 更新されたユーザー特典を返す
    const updatedBenefits = await getUserBenefits(user.id);
    return actionResponse.success({
      message: 'クレジットの控除が成功しました。',
      updatedBenefits,
    });
 
  } catch (e: any) {
    if (e.message === 'INSUFFICIENT_CREDITS') {
      return actionResponse.badRequest('クレジットが不足しています。');
    }
    return actionResponse.error(e.message || '予期しないサーバーエラーが発生しました。');
  }
}

控除戦略

システムは「サブスクリプションクレジット優先」戦略を使用します:

  1. まずサブスクリプションクレジット残高を控除
  2. サブスクリプションクレジットが不足している場合、次に一回限りのクレジットを控除
  3. 両方のタイプのクレジットが完全に活用されることを保証

クレジットの取り消し

一回限りのクレジットの取り消し

ユーザーが返金をリクエストした場合、対応する一回限りのクレジットを取り消します:

lib/payments/credit-manager.ts
export async function revokeOneTimeCredits(
  refundAmountCents: number,
  originalOrder: Order,
  refundOrderId: string
) {
  const planId = originalOrder.planId;
  const userId = originalOrder.userId;
 
  // 完全返金のみを処理
  const isFullRefund = refundAmountCents === Math.round(parseFloat(originalOrder.amountTotal!) * 100);
 
  if (isFullRefund) {
    const plan = await db
      .select({ benefitsJsonb: pricingPlansSchema.benefitsJsonb })
      .from(pricingPlansSchema)
      .where(eq(pricingPlansSchema.id, planId))
      .limit(1);
 
    const oneTimeToRevoke = plan.benefitsJsonb?.oneTimeCredits || 0;
 
    if (oneTimeToRevoke > 0) {
      await db.transaction(async (tx) => {
        const usage = await tx
          .select()
          .from(usageSchema)
          .where(eq(usageSchema.userId, userId))
          .for('update');
 
        if (!usage[0]) return;
 
        const newOneTimeBalance = Math.max(0, usage[0].oneTimeCreditsBalance - oneTimeToRevoke);
        const amountRevoked = usage[0].oneTimeCreditsBalance - newOneTimeBalance;
 
        if (amountRevoked > 0) {
          await tx.update(usageSchema)
            .set({ oneTimeCreditsBalance: newOneTimeBalance })
            .where(eq(usageSchema.userId, userId));
 
          await tx.insert(creditLogsSchema).values({
            userId,
            amount: -amountRevoked,
            oneTimeBalanceAfter: newOneTimeBalance,
            subscriptionBalanceAfter: usage[0].subscriptionCreditsBalance,
            type: 'refund_revoke',
            notes: `注文 ${originalOrder.id} の完全返金。`,
            relatedOrderId: originalOrder.id,
          });
        }
      });
    }
  }
}

サブスクリプションクレジットの取り消し

サブスクリプションが返金またはキャンセルされた場合、サブスクリプションクレジットを取り消します:

lib/payments/credit-manager.ts
export async function revokeSubscriptionCredits(originalOrder: Order) {
  const planId = originalOrder.planId;
  const userId = originalOrder.userId;
 
  const ctx = await getSubscriptionRevokeContext(planId, userId);
  if (!ctx) return;
 
  if (ctx.subscriptionToRevoke > 0) {
    await applySubscriptionCreditsRevocation({
      userId,
      amountToRevoke: ctx.subscriptionToRevoke,
      clearMonthly: ctx.clearMonthly,
      clearYearly: ctx.clearYearly,
      logType: 'refund_revoke',
      notes: `サブスクリプション注文 ${originalOrder.id} の完全返金。`,
      relatedOrderId: originalOrder.id,
    });
  }
}

サブスクリプション終了時のクレジット取り消し

サブスクリプションが期限切れまたはキャンセルされた場合、残りのサブスクリプションクレジットを取り消します:

lib/payments/credit-manager.ts
export async function revokeRemainingSubscriptionCreditsOnEnd(
  provider: PaymentProvider,
  subscriptionId: string,
  userId: string,
  metadata: any
) {
  const usage = await db
    .select({ subscriptionCreditsBalance: usageSchema.subscriptionCreditsBalance })
    .from(usageSchema)
    .where(eq(usageSchema.userId, userId))
    .limit(1);
 
  const amountToRevoke = usage[0]?.subscriptionCreditsBalance ?? 0;
 
  if (amountToRevoke > 0) {
    await applySubscriptionCreditsRevocation({
      userId,
      amountToRevoke,
      clearMonthly: true,
      clearYearly: true,
      logType: 'subscription_ended_revoke',
      notes: `${provider} サブスクリプション ${subscriptionId} が終了しました。残りのクレジットを取り消しました。`,
      relatedOrderId: null,
    });
  }
}

ユーザー特典のクエリ

ユーザー特典の取得

getUserBenefits関数を使用して、ユーザーの完全な特典情報をクエリします:

actions/usage/benefits.ts
export async function getUserBenefits(userId: string): Promise<UserBenefits> {
  // 1. クレジット残高をクエリ
  const usage = await db
    .select({
      subscriptionCreditsBalance: usageSchema.subscriptionCreditsBalance,
      oneTimeCreditsBalance: usageSchema.oneTimeCreditsBalance,
      balanceJsonb: usageSchema.balanceJsonb,
    })
    .from(usageSchema)
    .where(eq(usageSchema.userId, userId));
 
  // 2. 年額サブスクリプションの月次配分を処理
  if (usage[0]) {
    await processYearlySubscriptionCatchUp(userId);
  }
 
  // 3. サブスクリプション情報をクエリ
  const subscription = await fetchSubscriptionData(userId);
 
  // 4. 特典オブジェクトを構築
  return {
    activePlanId: subscription?.status === 'active' ? subscription.planId : null,
    subscriptionStatus: subscription?.status,
    currentPeriodEnd: subscription?.currentPeriodEnd,
    nextCreditDate: usage[0]?.balanceJsonb?.yearlyAllocationDetails?.nextCreditDate,
    totalAvailableCredits: (usage[0]?.subscriptionCreditsBalance || 0) + (usage[0]?.oneTimeCreditsBalance || 0),
    subscriptionCreditsBalance: usage[0]?.subscriptionCreditsBalance || 0,
    oneTimeCreditsBalance: usage[0]?.oneTimeCreditsBalance || 0,
  };
}

クライアントサイドのクエリ

フロントエンドはgetClientUserBenefits関数を使用できます:

actions/usage/benefits.ts
export async function getClientUserBenefits(): Promise<ActionResult<UserBenefits>> {
  const session = await getSession();
  const user = session?.user;
  if (!user) return actionResponse.unauthorized();
 
  try {
    const benefits = await getUserBenefits(user.id);
    return actionResponse.success(benefits);
  } catch (error: any) {
    return actionResponse.error(error.message || 'ユーザー特典の取得に失敗しました。');
  }
}

クレジットログのクエリ

ユーザークレジット履歴のクエリ

actions/usage/logs.ts
export async function getCreditLogs({
  pageIndex = 0,
  pageSize = 20,
}: ListCreditLogsParams = {}): Promise<ListCreditLogsResult> {
  const session = await getSession();
  const user = session?.user;
  if (!user) return actionResponse.unauthorized();
 
  const logs = await db
    .select()
    .from(creditLogsSchema)
    .where(eq(creditLogsSchema.userId, user.id))
    .orderBy(desc(creditLogsSchema.createdAt))
    .offset(pageIndex * pageSize)
    .limit(pageSize);
 
  const totalCount = await db
    .select({ value: count() })
    .from(creditLogsSchema)
    .where(eq(creditLogsSchema.userId, user.id));
 
  return actionResponse.success({
    logs: logs || [],
    count: totalCount[0]?.value ?? 0
  });
}

クレジット変更タイプ

システムは以下のクレジット変更タイプをサポートしています:

  • one_time_purchase: 一回限りの購入から獲得したクレジット
  • subscription_grant: サブスクリプションから付与されたクレジット(月次リセットまたは年次月次配分)
  • feature_usage: 機能使用から消費されたクレジット
  • refund_revoke: 返金から取り消されたクレジット
  • subscription_ended_revoke: サブスクリプション終了時に残りのクレジットが取り消された

よくある質問

Q: 年額サブスクリプションのクレジットはどのように配分されますか?

A: 年額サブスクリプションは「月次配分」戦略を使用します:

  1. サブスクライブ時に最初の月のクレジットをすぐに配分
  2. その後、ユーザーがクレジットをクエリするたびに、配分時間に達していれば、クレジットを1回配分
  3. 配分ロジックはgetUserBenefits関数で自動的に実行されます

Q: ユーザーがサブスクリプションクレジットと一回限りのクレジットの両方を持っている場合、控除時にどちらが優先されますか?

A: システムはサブスクリプションクレジットを優先的に控除し、次に一回限りのクレジットを控除します。これにより、サブスクリプションクレジットが完全に活用されます。

Q: サブスクリプションキャンセル後、残りのサブスクリプションクレジットは取り消されますか?

A: はい、サブスクリプションが期限切れまたはキャンセルされた場合、システムは自動的に残りのサブスクリプションクレジットを取り消します。

Q: ユーザーのクレジット履歴をクエリするにはどうすればよいですか?

A: getCreditLogs関数を使用して、ユーザーのクレジット変更履歴をクエリします。ページネーションをサポートしています。

下一步