Menu

Credit System

The credit system is one of the core features of NEXTY.DEV's payment system. It manages credits that users obtain through purchasing plans and tracks credit usage. This document details the design and usage of the credit system.

Credit System Overview

Credit Types

The system supports two types of credits:

  1. One-time Credits
  • Obtained through one-time payments
  • Do not expire
  • Decrease after use and are not automatically replenished
  1. Subscription Credits
  • Obtained through subscription plans
  • Allocated monthly or yearly
  • Periodically reset or replenished

Credit Balance Storage

Credit balances are stored in the usage table:

{
  userId: string;                    // User ID (unique)
  subscriptionCreditsBalance: number; // Subscription credits balance
  oneTimeCreditsBalance: number;     // One-time credits balance
  balanceJsonb: {                    // Balance details (JSONB)
    monthlyAllocationDetails?: {     // Monthly subscription details
      monthlyCredits: number;
      relatedOrderId: string;
    };
    yearlyAllocationDetails?: {      // Yearly subscription details
      remainingMonths: number;
      nextCreditDate: string;
      monthlyCredits: number;
      lastAllocatedMonth: string;
      relatedOrderId: string;
    };
  };
}

Credit Logs

All credit changes are recorded in the credit_logs table:

{
  userId: string;
  amount: number;                   // Change amount (positive for increase, negative for decrease)
  oneTimeBalanceAfter: number;      // One-time credits balance after change
  subscriptionBalanceAfter: number; // Subscription credits balance after change
  type: string;                     // Change type
  notes: string;                    // Notes
  relatedOrderId?: string;          // Related order ID
  createdAt: Date;                  // Creation time
}

Credit Granting

One-time Credit Granting

When users purchase a one-time payment plan, the system grants one-time credits:

lib/payments/credit-manager.ts
export async function upgradeOneTimeCredits(
  userId: string,
  planId: string,
  orderId: string
) {
  // 1. Get plan benefits configuration
  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. Update credit balance using transaction
    await db.transaction(async (tx) => {
      // Update or insert credit balance
      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. Record credit log
      await tx.insert(creditLogsSchema).values({
        userId,
        amount: creditsToGrant,
        oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
        subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
        type: 'one_time_purchase',
        notes: 'One-time credit purchase',
        relatedOrderId: orderId,
      });
    });
  }
}

Subscription Credit Granting

Monthly Subscription

Monthly subscriptions reset credits on each renewal:

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) => {
      // Update subscription credits balance (reset to monthly credits)
      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,
        });
 
      // Record credit log
      await tx.insert(creditLogsSchema).values({
        userId,
        amount: monthlyCredits,
        oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
        subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
        type: 'subscription_grant',
        notes: 'Subscription credits granted/reset',
        relatedOrderId: orderId,
      });
    });
  }
}

Yearly Subscription

Yearly subscriptions use a monthly allocation approach:

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,
      }
    };
 
    // Initialize yearly subscription, immediately allocate first month's credits
    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,
      });
 
    // Record credit log
    await tx.insert(creditLogsSchema).values({
      userId,
      amount: benefits.monthlyCredits,
      oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
      subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
      type: 'subscription_grant',
      notes: 'Yearly plan initial credits granted',
      relatedOrderId: orderId,
    });
  });
}

Yearly Subscription Monthly Allocation

Yearly subscriptions use a "monthly allocation" strategy. The system automatically allocates credits each month:

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;
 
      // Check if credits need to be allocated
      if (
        !yearlyDetails ||
        yearlyDetails.remainingMonths <= 0 ||
        !yearlyDetails.nextCreditDate ||
        new Date() < new Date(yearlyDetails.nextCreditDate)
      ) {
        return false; // No allocation needed
      }
 
      // Allocate this month's credits
      const creditsToAllocate = yearlyDetails.monthlyCredits;
      const newRemainingMonths = yearlyDetails.remainingMonths - 1;
      const nextCreditDate = new Date(yearlyDetails.nextCreditDate);
      nextCreditDate.setMonth(nextCreditDate.getMonth() + 1);
 
      // Update credit balance
      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));
 
      // Record credit log
      await tx.insert(creditLogsSchema).values({
        userId,
        amount: creditsToAllocate,
        type: 'subscription_grant',
        notes: 'Yearly subscription monthly credits allocated',
        relatedOrderId: yearlyDetails.relatedOrderId,
        createdAt: new Date(yearlyDetails.nextCreditDate), // Use allocation date
      });
 
      return newRemainingMonths > 0; // Continue loop if there are remaining months
    });
  }
}

Yearly subscription monthly credit reset does not use scheduled tasks. Instead, it's designed to automatically check and update each time user benefits are queried. This approach significantly reduces database requests and improves performance compared to scheduled task processing.

Credit Usage

Deducting Credits

When users consume credits by using features, call the deductCredits function:

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('Amount to deduct must be positive.');
  }
 
  try {
    await db.transaction(async (tx) => {
      // Lock user credit record
      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');
      }
 
      // Prioritize deducting subscription credits, then one-time 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;
 
      // Update credit balance
      await tx.update(usageSchema)
        .set({
          subscriptionCreditsBalance: newSubBalance,
          oneTimeCreditsBalance: newOneTimeBalance,
        })
        .where(eq(usageSchema.userId, user.id));
 
      // Record credit log
      await tx.insert(creditLogsSchema)
        .values({
          userId: user.id,
          amount: -amountToDeduct,
          oneTimeBalanceAfter: newOneTimeBalance,
          subscriptionBalanceAfter: newSubBalance,
          type: 'feature_usage',
          notes: notes,
        });
    });
 
    // Return updated user benefits
    const updatedBenefits = await getUserBenefits(user.id);
    return actionResponse.success({
      message: 'Credits deducted successfully.',
      updatedBenefits,
    });
 
  } catch (e: any) {
    if (e.message === 'INSUFFICIENT_CREDITS') {
      return actionResponse.badRequest('Insufficient credits.');
    }
    return actionResponse.error(e.message || 'An unexpected server error occurred.');
  }
}

Deduction Strategy

The system uses a "prioritize subscription credits" strategy:

  1. First deduct subscription credits balance
  2. If subscription credits are insufficient, then deduct one-time credits
  3. Ensure both types of credits are fully utilized

Credit Revocation

One-time Credit Revocation

When users request refunds, revoke corresponding one-time credits:

lib/payments/credit-manager.ts
export async function revokeOneTimeCredits(
  refundAmountCents: number,
  originalOrder: Order,
  refundOrderId: string
) {
  const planId = originalOrder.planId;
  const userId = originalOrder.userId;
 
  // Only process full refunds
  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: `Full refund for order ${originalOrder.id}.`,
            relatedOrderId: originalOrder.id,
          });
        }
      });
    }
  }
}

Subscription Credit Revocation

When subscriptions are refunded or canceled, revoke subscription credits:

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: `Full refund for subscription order ${originalOrder.id}.`,
      relatedOrderId: originalOrder.id,
    });
  }
}

Credit Revocation on Subscription End

When subscriptions expire or are canceled, revoke remaining subscription credits:

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} subscription ${subscriptionId} ended; remaining credits revoked.`,
      relatedOrderId: null,
    });
  }
}

Querying User Benefits

Get User Benefits

Use the getUserBenefits function to query a user's complete benefits information:

actions/usage/benefits.ts
export async function getUserBenefits(userId: string): Promise<UserBenefits> {
  // 1. Query credit balance
  const usage = await db
    .select({
      subscriptionCreditsBalance: usageSchema.subscriptionCreditsBalance,
      oneTimeCreditsBalance: usageSchema.oneTimeCreditsBalance,
      balanceJsonb: usageSchema.balanceJsonb,
    })
    .from(usageSchema)
    .where(eq(usageSchema.userId, userId));
 
  // 2. Process yearly subscription monthly allocation
  if (usage[0]) {
    await processYearlySubscriptionCatchUp(userId);
  }
 
  // 3. Query subscription information
  const subscription = await fetchSubscriptionData(userId);
 
  // 4. Build benefits object
  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,
  };
}

Client-side Query

Frontend can use the getClientUserBenefits function:

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 || 'Failed to fetch user benefits.');
  }
}

Credit Log Query

Query User Credit History

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

Credit Change Types

The system supports the following credit change types:

  • one_time_purchase: Credits obtained from one-time purchase
  • subscription_grant: Credits granted from subscription (monthly reset or yearly monthly allocation)
  • feature_usage: Credits consumed from feature usage
  • refund_revoke: Credits revoked from refund
  • subscription_ended_revoke: Remaining credits revoked when subscription ends

Frequently Asked Questions

Q: How are credits allocated for yearly subscriptions?

A: Yearly subscriptions use a "monthly allocation" strategy:

  1. Immediately allocate the first month's credits when subscribing
  2. Each month thereafter, when users query credits, if the allocation time is reached, allocate credits once
  3. The allocation logic automatically executes in the getUserBenefits function

Q: If a user has both subscription credits and one-time credits, which is prioritized when deducting?

A: The system prioritizes deducting subscription credits, then one-time credits. This ensures subscription credits are fully utilized.

Q: Will remaining subscription credits be revoked after subscription cancellation?

A: Yes, when a subscription expires or is canceled, the system automatically revokes remaining subscription credits.

Q: How to query a user's credit history?

A: Use the getCreditLogs function to query a user's credit change history, with pagination support.

下一步