Credit System

Credit-based pricing with atomic deductions, monthly resets, bonus purchases, and per-operation costs

Kit includes a complete credit system for AI-intensive applications. Users receive a monthly credit allocation based on their subscription tier, and credits are deducted per AI operation. The system uses atomic database transactions to prevent race conditions, maintains an immutable ledger for audit trails, and supports bonus credit top-up purchases.
The credit system is only active when NEXT_PUBLIC_PRICING_MODEL=credit_based. In classic SaaS mode, all AI operations are unlimited (no credit tracking).

How Credits Work

The credit lifecycle follows a monthly cycle aligned to the billing period:
Subscription Created
    |
    v
Credits Initialized (tier allocation)
    |--- Free: 500 credits
    |--- Basic: 1,500 credits
    |--- Pro: 5,000 credits
    |--- Enterprise: 15,000 credits
    |
    v
User Performs AI Operations
    |--- Each operation costs credits (5 - 80)
    |--- Atomic deduction with SELECT FOR UPDATE
    |--- Immutable CreditTransaction log entry
    |
    v
Monthly Reset (triggered by billing payment)
    |--- subscription_payment_success webhook
    |--- Credits restored to tier allocation
    |--- creditsResetAt updated to NOW
    |--- Auto-reset backup after 30 days
    |
    v
Cycle Repeats

Credit Allocation by Tier

Each tier receives a monthly credit allocation, configurable via environment variables:
TierDefault CreditsEnvironment VariableFallback
Free500CREDIT_SYSTEM_FREE_CREDITS500
Basic1,500CREDIT_SYSTEM_BASIC_CREDITS1,500
Pro5,000CREDIT_SYSTEM_PRO_CREDITS5,000
Enterprise15,000CREDIT_SYSTEM_ENTERPRISE_CREDITS15,000
The getDefaultCreditsByTier() function reads these values with fallback protection:
src/lib/credits/config.ts — getDefaultCreditsByTier()
export function getDefaultCreditsByTier(tier: SubscriptionTier): number {
  const tierCredits: CreditTierMap = {
    free: parseCreditsWithFallback(process.env.CREDIT_SYSTEM_FREE_CREDITS, 500),
    basic: parseCreditsWithFallback(
      process.env.CREDIT_SYSTEM_BASIC_CREDITS,
      1500
    ),
    pro: parseCreditsWithFallback(process.env.CREDIT_SYSTEM_PRO_CREDITS, 5000),
    enterprise: parseCreditsWithFallback(
      process.env.CREDIT_SYSTEM_ENTERPRISE_CREDITS,
      15000
    ),
  }

  return tierCredits[tier]
}

Credit System Feature Flag

The credit system activates based on the pricing model environment variable. The isCreditSystemEnabled() function is the single source of truth:
src/lib/credits/config.ts — isCreditSystemEnabled()
export function isCreditSystemEnabled(): boolean {
  // Return cached value if available
  if (creditSystemEnabledCache !== null) {
    return creditSystemEnabledCache
  }

  // Check pricing model (credit_based = credits enabled, classic_saas = unlimited)
  const pricingModel = process.env.NEXT_PUBLIC_PRICING_MODEL

  // CRITICAL: Distinguish between test types (unit vs E2E)
  // Unit tests (Vitest): ENABLE credit system to test credit logic
  //   - Set ENABLE_CREDIT_SYSTEM_IN_TESTS=true in vitest.config.ts
  //   - Mock Prisma directly for controlled testing
  // E2E tests (Playwright): DISABLE credit system (no database in CI)
  //   - Do NOT set ENABLE_CREDIT_SYSTEM_IN_TESTS flag
  //   - Tests run without database write operations
  const isUnitTest = process.env.ENABLE_CREDIT_SYSTEM_IN_TESTS === 'true'
  const isE2ETest = process.env.NODE_ENV === 'test' && !isUnitTest

  const enabled = pricingModel === 'credit_based' && !isE2ETest

  // Cache the result
  creditSystemEnabledCache = enabled

  // Log status once in development
  if (
    process.env.NODE_ENV === 'development' &&
    !globalThis.__creditSystemLoggedOnce
  ) {
    console.log(
      `[Credit System] ${enabled ? 'Enabled' : 'Disabled'} ` +
        `(MODEL=${pricingModel || 'undefined'}, isUnitTest=${isUnitTest}, isE2ETest=${isE2ETest})`
    )
    globalThis.__creditSystemLoggedOnce = true
  }

  return enabled
}
When the credit system is disabled (classic SaaS mode), all credit operations return success with an "unlimited" balance of 999,999. This means your AI operation code does not need conditional checks — it always calls deductCredits(), and the function handles the model switching internally.

Credit Deduction

Atomic Operations

Credit deductions use PostgreSQL SELECT FOR UPDATE row-level locking to prevent race conditions. This is critical for AI applications where multiple concurrent requests might try to deduct credits simultaneously.
src/lib/credits/credit-manager.ts — deductCredits() with SELECT FOR UPDATE
export async function deductCredits(params: {
  userId: string
  operation: CreditOperation
  quantity?: number
  metadata?: Prisma.InputJsonValue
}): Promise<CreditOperationResult> {
  const { userId, operation, quantity = 1, metadata } = params

  // Calculate cost based on operation type and quantity
  const cost = calculateBatchCost(operation, quantity)

  console.log(
    `[Credits] Deducting ${cost} credits for ${operation} (x${quantity})`
  )

  // Skip if credit system disabled
  if (!isCreditSystemEnabled()) {
    console.log('[Credits] System disabled - skipping deduction')
    return {
      success: true,
      balance: 999999, // Unlimited
      message: 'Credit system disabled',
    }
  }

  try {
    // Ensure credits initialized before deducting
    await ensureUserCreditsInitializedSafe(userId)

    // Use transaction to atomically check balance and deduct credits
    const result = await prisma.$transaction(
      async (tx) => {
        // CRITICAL: Lock user row with SELECT FOR UPDATE to prevent race conditions
        // This ensures only ONE transaction can read and modify the balance at a time
        const userRows = await tx.$queryRaw<
          Array<{ creditBalance: Decimal; creditsPerMonth: Decimal }>
        >`
        SELECT "creditBalance", "creditsPerMonth"
        FROM "User"
        WHERE "id" = ${userId}
        FOR UPDATE
      `

        if (!userRows || userRows.length === 0) {

deductCredits() Function

The deductCredits() function is the primary API for consuming credits:
typescript
import { deductCredits } from '@/lib/credits/credit-manager'

const result = await deductCredits({
  userId: 'user_database_id',
  operation: 'chat_message',  // Type-safe operation name
  quantity: 1,                 // Number of operations (default: 1)
  metadata: { model: 'gpt-4' } // Optional metadata for the ledger
})

if (result.success) {
  // Proceed with the AI operation
  console.log(`Remaining balance: ${result.balance}`)
} else {
  // Handle insufficient credits
  console.log(result.message) // "Insufficient credits. Required: 1, Available: 0"
}
Parameters:
ParameterTypeRequiredDescription
userIdstringYesDatabase user ID
operationCreditOperationYesOne of 21 operation types
quantitynumberNoBatch multiplier (default: 1)
metadataJsonValueNoAdditional data for the audit log
Return type:
FieldTypeDescription
successbooleanWhether the deduction succeeded
balancenumberCredit balance after the operation
messagestring?Error message if deduction failed

Insufficient Credits

When a user does not have enough credits, deductCredits() returns { success: false } without modifying the balance. The calling code should handle this gracefully:
typescript
const result = await deductCredits({ userId, operation: 'image_gen' })

if (!result.success) {
  return NextResponse.json(
    {
      error: 'Insufficient credits',
      required: getCreditCost('image_gen'),
      available: result.balance,
      upgradeUrl: '/dashboard/billing',
    },
    { status: 402 }
  )
}

Credit Costs per Operation

Each AI operation has a defined credit cost in credit-costs.ts. Costs are based on estimated token usage and computational complexity:
OperationCreditsCategoryDescription
embedding_single5EmbeddingsSingle text embedding
vector_search5EmbeddingsSemantic search query
faq_simple5FAQSimple RAG lookup
embedding_batch10EmbeddingsBatch embeddings
pdf_parse15DocumentPDF text extraction
faq_complex15FAQMulti-step reasoning
chat_message15ChatStandard chat message
tts20AudioText-to-speech
speech_to_text20AudioVoice input transcription
chat_streaming20ChatStreaming chat message
content_generation25ContentTemplate-based text generation
transcription30AudioSpeech-to-text
ocr30DocumentImage text extraction
image_analysis30Advanced AIImage analysis
chat_with_tools30ChatChat with function calling
pdf_analysis40DocumentPDF analysis
code_analysis40Advanced AICode review and analysis
image_edit50Advanced AIImage editing/manipulation
code_gen50Advanced AICode generation from specs
document_summary65DocumentDocument summarization
image_gen80Advanced AIText-to-image generation
To add custom operations, extend the CREDIT_COSTS object in apps/boilerplate/src/lib/credits/credit-costs.ts. The CreditOperation type is auto-generated from the object keys, so TypeScript will enforce the new operation across the codebase.

Monthly Credit Resets

Credits reset to the tier allocation when the user's billing payment succeeds. Kit supports two reset triggers:

Primary: Webhook Trigger

The subscription_payment_success webhook event triggers a credit reset:
  1. Lemon Squeezy processes the monthly payment
  2. Webhook fires with subscription_payment_success event
  3. Handler calls resetMonthlyCredits(userId, 'webhook')
  4. Balance is set to creditsPerMonth, creditsResetAt is set to NOW
  5. A monthly_reset transaction is logged in the credit ledger

Backup: Auto-Reset

If the webhook fails (network issues, server downtime), an auto-reset mechanism checks whether credits are overdue for a reset. If creditsResetAt is more than 30 days in the past and the user has an active subscription, credits are reset automatically on the next credit check.

Bonus Credits

Users can purchase additional credits beyond their monthly allocation.

Purchasing Top-ups

Each tier offers up to 2 bonus credit packages, configured via environment variables:
bash
# Basic tier packages
NEXT_PUBLIC_BONUS_BASIC_PACKAGE1_CREDITS="500"
NEXT_PUBLIC_BONUS_BASIC_PACKAGE1_PRICE="4.99"
NEXT_PUBLIC_BONUS_BASIC_PACKAGE1_VARIANT_ID="334455"

NEXT_PUBLIC_BONUS_BASIC_PACKAGE2_CREDITS="1200"
NEXT_PUBLIC_BONUS_BASIC_PACKAGE2_PRICE="9.99"
NEXT_PUBLIC_BONUS_BASIC_PACKAGE2_VARIANT_ID="334456"
Bonus credit purchases are processed through the order_created webhook event (one-time purchases, not subscriptions). See Webhooks for the full webhook flow.

Monthly Purchase Limits

Each tier has a configurable monthly purchase limit that caps how many bonus credits a user can buy per calendar month:
TierEnv VariableDefault
Free0 (cannot purchase)
BasicNEXT_PUBLIC_BONUS_BASIC_MAX_PER_MONTH3,000
ProNEXT_PUBLIC_BONUS_PRO_MAX_PER_MONTH10,000
EnterpriseNEXT_PUBLIC_BONUS_ENTERPRISE_MAX_PER_MONTH40,000
The limit is enforced both in the UI (the purchase button disables when the limit is reached) and server-side (the /api/credits/checkout endpoint returns 403). Purchase counts reset at the start of each calendar month.

Purchase Flow

The bonus credit purchase flow involves the dashboard UI, two API endpoints, and a webhook:
/dashboard/credits
    |
    v
BonusPackages component (loads via GET /api/credits/bonus-packages)
    |--- Displays tier-specific packages with prices
    |--- Shows "Best Value" badge on cheapest per-credit package
    |--- Disables purchases when monthly limit reached
    |
    v
User clicks "Buy" → POST /api/credits/checkout
    |--- Validates variant ID against tier packages
    |--- Checks monthly purchase limit
    |--- Creates Lemon Squeezy checkout (one-time purchase)
    |
    v
Lemon Squeezy checkout page → User completes payment
    |
    v
order_created webhook → purchaseBonusCredits()
    |--- Idempotency check (lemonSqueezyId unique constraint)
    |--- Increments user.bonusCredits atomically
    |--- Records BonusCreditPurchase with expiration date
    |--- Logs "purchase" transaction in credit ledger
    |--- Sends confirmation email (non-blocking)
    |
    v
Redirect to /dashboard/credits?purchase=success
    |--- Success banner with auto-dismiss after 30 seconds
    |--- Polls credit balance for 30s to reflect new purchase

Expiration

Bonus credits expire after a configurable period (default: 12 months). The NEXT_PUBLIC_BONUS_CREDITS_EXPIRY_MONTHS environment variable controls this. Expired bonus credits are not automatically deducted — the expiration is checked at consumption time.

Consumption Priority

The credit system supports two consumption strategies controlled by NEXT_PUBLIC_BONUS_CREDITS_CONSUME_FIRST:
Bonus-first (CONSUME_FIRST=true, default):
User has: 50 bonus credits + 100 subscription credits
Deduct 75 credits:

1. Bonus only (if bonus >= cost)
   → Use 75 bonus credits

2. Mixed (if bonus < cost but total >= cost)
   → Use 50 bonus + 25 subscription credits

3. Insufficient (if total < cost)
   → Deduction fails, no credits consumed
Subscription-first (CONSUME_FIRST=false):
User has: 50 bonus credits + 100 subscription credits
Deduct 75 credits:

1. Subscription only (if subscription >= cost)
   → Use 75 subscription credits

2. Bonus fallback (if subscription < cost but total >= cost)
   → Use 100 subscription + draw remaining from bonus

3. Insufficient (if total < cost)
   → Deduction fails, no credits consumed
The consumeCreditsWithBonus() function in the credit manager handles both strategies atomically with the same SELECT FOR UPDATE locking pattern as regular deductions. Internally, it delegates to consumeBonusFirst() or consumeSubscriptionFirst() based on the consumeFirst configuration.

Per-User Toggle

When NEXT_PUBLIC_BONUS_CREDITS_USER_TOGGLE_ENABLED=true, individual users can control whether their bonus credits are consumed automatically. This adds a bonusCreditsAutoUse Boolean field to the User model (defaults to false).
How it works:
  1. The credit system checks the user's bonusCreditsAutoUse preference before consuming bonus credits
  2. If the user has not opted in, bonus credits are skipped even when available
  3. A soft-prompt dialog appears when the user's subscription credits are low, suggesting they enable bonus credit consumption
  4. Users manage their preference via the /api/credits/preferences endpoint (GET and PATCH)
The useBonusPreference hook provides client-side state management for the toggle, backed by TanStack Query with optimistic updates.

Credit Ledger

Every credit operation creates an immutable CreditTransaction record in the database. This provides a complete audit trail:
FieldTypeDescription
idStringUnique transaction ID
userIdStringUser who owns this transaction
amountDecimal(10,2)Credit change (negative for deductions, positive for refunds/resets)
balanceAfterDecimal(10,2)Credit balance after this transaction
typeStringusage, refund, monthly_reset, purchase, or adjustment
operationString?Operation type (e.g., chat_message, image_gen)
metadataJson?Additional context (model, quantity, trigger source)
createdAtDateTimeWhen the transaction occurred

Querying the Ledger

Use the credit ledger for analytics, dispute resolution, and usage reporting:
typescript
// Get all transactions for a user
const transactions = await prisma.creditTransaction.findMany({
  where: { userId },
  orderBy: { createdAt: 'desc' },
  take: 50,
})

// Get usage summary by operation type
const usageSummary = await prisma.creditTransaction.groupBy({
  by: ['operation'],
  where: { userId, type: 'usage' },
  _sum: { amount: true },
  _count: true,
})

Tier Change Adjustments

When a user changes tiers, credits are adjusted to match the new allocation:

Upgrades

On upgrade, the user receives the new tier's full credit allocation. The updateUserCredits() helper (called from the subscription_updated webhook handler) sets both creditsPerMonth and creditBalance to the new tier's values.

Downgrades

On downgrade, the credit allocation is reduced at the next monthly reset. If the user currently has more credits than the new tier allows, they keep the excess until the next reset cycle. At reset, the balance is set to the lower tier's allocation.

Free Tier Fallback

When a subscription expires or is cancelled past the grace period, the user reverts to the Free tier with 500 credits per month. Any remaining paid credits are preserved until the next automatic reset.