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:
| Tier | Default Credits | Environment Variable | Fallback |
|---|---|---|---|
| Free | 500 | CREDIT_SYSTEM_FREE_CREDITS | 500 |
| Basic | 1,500 | CREDIT_SYSTEM_BASIC_CREDITS | 1,500 |
| Pro | 5,000 | CREDIT_SYSTEM_PRO_CREDITS | 5,000 |
| Enterprise | 15,000 | CREDIT_SYSTEM_ENTERPRISE_CREDITS | 15,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) {
Without row-level locking, two concurrent AI requests could both read a balance of 10 credits, both attempt to deduct 8, and both succeed — leaving the user with a negative balance.
SELECT FOR UPDATE ensures only one transaction can read and modify the balance at a time.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:
| Parameter | Type | Required | Description |
|---|---|---|---|
userId | string | Yes | Database user ID |
operation | CreditOperation | Yes | One of 21 operation types |
quantity | number | No | Batch multiplier (default: 1) |
metadata | JsonValue | No | Additional data for the audit log |
Return type:
| Field | Type | Description |
|---|---|---|
success | boolean | Whether the deduction succeeded |
balance | number | Credit balance after the operation |
message | string? | 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 }
)
}
Kit stores
creditBalance and creditsPerMonth as Decimal fields in Prisma. This avoids floating-point precision issues that would occur with JavaScript number types. The credit manager converts between Decimal and number at the boundary.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:| Operation | Credits | Category | Description |
|---|---|---|---|
embedding_single | 5 | Embeddings | Single text embedding |
vector_search | 5 | Embeddings | Semantic search query |
faq_simple | 5 | FAQ | Simple RAG lookup |
embedding_batch | 10 | Embeddings | Batch embeddings |
pdf_parse | 15 | Document | PDF text extraction |
faq_complex | 15 | FAQ | Multi-step reasoning |
chat_message | 15 | Chat | Standard chat message |
tts | 20 | Audio | Text-to-speech |
speech_to_text | 20 | Audio | Voice input transcription |
chat_streaming | 20 | Chat | Streaming chat message |
content_generation | 25 | Content | Template-based text generation |
transcription | 30 | Audio | Speech-to-text |
ocr | 30 | Document | Image text extraction |
image_analysis | 30 | Advanced AI | Image analysis |
chat_with_tools | 30 | Chat | Chat with function calling |
pdf_analysis | 40 | Document | PDF analysis |
code_analysis | 40 | Advanced AI | Code review and analysis |
image_edit | 50 | Advanced AI | Image editing/manipulation |
code_gen | 50 | Advanced AI | Code generation from specs |
document_summary | 65 | Document | Document summarization |
image_gen | 80 | Advanced AI | Text-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:- Lemon Squeezy processes the monthly payment
- Webhook fires with
subscription_payment_successevent - Handler calls
resetMonthlyCredits(userId, 'webhook') - Balance is set to
creditsPerMonth,creditsResetAtis set toNOW - A
monthly_resettransaction 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.The
creditsResetAt timestamp is set to NOW at reset time, not to a fixed calendar date. This aligns the credit cycle exactly with the billing cycle, even if payments are delayed or retried.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:
| Tier | Env Variable | Default |
|---|---|---|
| Free | — | 0 (cannot purchase) |
| Basic | NEXT_PUBLIC_BONUS_BASIC_MAX_PER_MONTH | 3,000 |
| Pro | NEXT_PUBLIC_BONUS_PRO_MAX_PER_MONTH | 10,000 |
| Enterprise | NEXT_PUBLIC_BONUS_ENTERPRISE_MAX_PER_MONTH | 40,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
Bonus credit packages use Lemon Squeezy one-time purchases (
order_created event), not subscription billing. Each purchase is a standalone transaction with no recurring charges. When setting up products in Lemon Squeezy, create them as one-time payment products — see Lemon Squeezy Setup for configuration steps.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
Bonus-first (default) protects the user's monthly subscription allocation and ensures bonus credits are used before they expire. Subscription-first preserves purchased bonus credits as a reserve while consuming the renewable monthly allocation.
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:
- The credit system checks the user's
bonusCreditsAutoUsepreference before consuming bonus credits - If the user has not opted in, bonus credits are skipped even when available
- A soft-prompt dialog appears when the user's subscription credits are low, suggesting they enable bonus credit consumption
- Users manage their preference via the
/api/credits/preferencesendpoint (GET and PATCH)
The
useBonusPreference hook provides client-side state management for the toggle, backed by TanStack Query with optimistic updates.The per-user toggle is disabled by default. Set
NEXT_PUBLIC_BONUS_CREDITS_USER_TOGGLE_ENABLED=true in your environment to enable it. When disabled, the system-wide CONSUME_FIRST setting applies to all users.Credit Ledger
Every credit operation creates an immutable
CreditTransaction record in the database. This provides a complete audit trail:| Field | Type | Description |
|---|---|---|
id | String | Unique transaction ID |
userId | String | User who owns this transaction |
amount | Decimal(10,2) | Credit change (negative for deductions, positive for refunds/resets) |
balanceAfter | Decimal(10,2) | Credit balance after this transaction |
type | String | usage, refund, monthly_reset, purchase, or adjustment |
operation | String? | Operation type (e.g., chat_message, image_gen) |
metadata | Json? | Additional context (model, quantity, trigger source) |
createdAt | DateTime | When the transaction occurred |
Always use
deductCredits(), refundCredits(), or resetMonthlyCredits() to change a user's credit balance. These functions maintain the atomic transaction guarantee and create audit log entries. Direct SQL updates to User.creditBalance will create an inconsistency between the balance and the ledger.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.