Credits System Overview
Learn about the comprehensive credits system in NEXTDEVKIT for managing user credits, consumption, and monetization
🎯 What is the Credits System?
The Credits System in NEXTDEVKIT is a flexible, production-ready monetization framework designed for AI applications, API services, and any usage-based billing model. It provides a complete solution for:
- 💰 One-time credit purchases - Users buy credit packages
- 🎁 Subscription credits - Monthly credit grants for subscribers
- 🎉 Registration bonuses - Welcome credits for new users
- 📊 Usage tracking - Precise credit consumption monitoring
- ⏰ Expiration management - Automatic credit expiration with FIFO
- 🔄 Automated grants - Scheduled monthly credit distribution
🎨 Why Use the Credits System?
Perfect for AI & API Services
If you're building AI applications, API platforms, or any service that requires usage-based billing, the credits system provides:
- Flexible Monetization: Combine subscriptions with pay-as-you-go credits
- User Freedom: Let free users purchase credit packages without forcing subscriptions
- Predictable Revenue: Mix recurring and one-time revenue streams
- Engagement Tools: Registration bonuses to reduce friction for new users
Key Benefits
- ✅ FIFO Expiration: Fair credit consumption with First-In-First-Out
- ✅ Double-Entry Accounting: Accurate financial tracking and auditing
- ✅ Automated Management: Cron jobs for grants and expiration
- ✅ Payment Integration: Seamless Stripe/Creem integration
- ✅ Type-Safe: Full TypeScript support with Drizzle ORM
- ✅ Production-Ready: Battle-tested with proper error handling
🏗️ System Architecture
Credits Flow
┌─────────────────────────────────────────────────────────────┐
│ Credit Sources │
├─────────────────────────────────────────────────────────────┤
│ • Registration Bonus • Credit Packages • Subscriptions │
└────────────────┬────────────────────────────────────────────┘
│
▼
┌────────────────────────┐
│ Credit Batches │
│ (FIFO Queue) │
│ │
│ ┌──────────────────┐ │
│ │ Batch 1: 100 credits│ ← Oldest (expires first)
│ ├──────────────────┤ │
│ │ Batch 2: 200 credits│
│ ├──────────────────┤ │
│ │ Batch 3: 50 credits │ ← Newest
│ └──────────────────┘ │
└────────────┬───────────┘
│
▼
┌────────────────────────┐
│ Credit Consumption │
│ (Service Usage) │
└────────────────────────┘
Database Schema
The system uses three main tables:
credits_balance
- User credit balance
- id: User balance ID
- userId: User reference
- balance: Available credits
- totalEarned: Lifetime credits earned
- totalSpent: Lifetime credits spent
- status: Account status (ACTIVE/FROZEN)
credits_batch
- FIFO credit batches
- id: Batch ID
- userId: User reference
- amount: Original credit amount
- remaining: Remaining credits
- issuedAt: Creation timestamp
- expiresAt: Expiration date (nullable)
- status: ACTIVE/CONSUMED/EXPIRED
- sourceType: PURCHASE/SUBSCRIPTION/BONUS
- sourcePlan: Plan identifier
- grantPeriod: YYYY-MM format for monthly grants
credits_transaction
- Transaction history
- id: Transaction ID
- userId: User reference
- type: purchase/consumption/monthly_grant/registration_bonus/expiration
- amount: Credit amount (positive for grants, negative for consumption)
- debitAccount: Double-entry debit account
- creditAccount: Double-entry credit account
- referenceId: Unique transaction reference
- status: COMPLETED/FAILED
Why Double-Entry Accounting?
The credits system uses double-entry accounting principles for maximum accuracy and auditability. This is a critical design decision that provides several key benefits:
What is Double-Entry Accounting?
Every credit transaction records two sides of the transaction:
- Debit Account - Where credits come from
- Credit Account - Where credits go to
Example Transaction:
{
type: "purchase",
amount: 100,
debitAccount: "PAYMENT:stripe_payment_123", // Source
creditAccount: "WALLET:user_123", // Destination
}
Why This Design?
1. Financial Accuracy & Auditability
Traditional single-entry systems can lose track of credits:
❌ Single-entry: "User gained 100 credits"
Problem: Where did they come from? Can't trace the source.
✅ Double-entry: "100 credits moved from PAYMENT:xyz to WALLET:user_123"
Clear: Money trail is complete and traceable.
2. Fraud Prevention & Detection
The system can detect anomalies:
// If total debits ≠ total credits, something is wrong
const totalDebits = sumAllDebitTransactions();
const totalCredits = sumAllCreditTransactions();
if (totalDebits !== totalCredits) {
alert("⚠️ Accounting inconsistency detected!");
}
3. Comprehensive Financial Reporting
You can generate reports like:
- How many credits were purchased vs consumed this month?
- Which services consumed the most credits?
- How much revenue is tied to unused credits?
// Example: Track revenue from credit purchases
SELECT SUM(amount) FROM credits_transaction
WHERE debitAccount LIKE 'PAYMENT:%'
AND createdAt >= '2024-01-01'
4. Regulatory Compliance
For businesses handling money or credits as currency:
- SOC 2 Compliance: Audit trail of all transactions
- Financial Audits: Clear money flow documentation
- Tax Reporting: Accurate revenue recognition
Account Structure
The system uses these account types:
System Accounts (Credit Sources):
SYSTEM:subscription-grant
- Monthly subscription creditsSYSTEM:registration-bonus
- Welcome bonusesPAYMENT:purchase_id
- Purchased creditsSYSTEM:expiration
- Expired credits cleanup
User Accounts (Credit Destinations):
WALLET:user_id
- User credit balanceSERVICE:service_name
- Service consumption
Transaction Flow Example:
Purchase:
PAYMENT:stripe_xyz_123 → WALLET:user_123 (100 credits)
Consumption:
WALLET:user_123 → SERVICE:google:chat (2 credits)
Expiration:
WALLET:user_123 → SYSTEM:expiration (50 credits)
Benefits Over Simple Balance Tracking
Simple Balance (❌):
UPDATE user_balance SET balance = balance + 100
-- Problems:
-- - No history of where credits came from
-- - Can't trace discrepancies
-- - No audit trail
-- - Easy to make mistakes
Double-Entry (✅):
-- Transaction 1: Record the transfer
INSERT INTO credits_transaction (
debitAccount: 'PAYMENT:xyz',
creditAccount: 'WALLET:user_123',
amount: 100
)
-- Transaction 2: Update batch and balance
-- Both are tracked and verifiable
Real-World Benefits
Scenario 1: User Dispute
User: "I purchased 500 credits but only have 400!"
With double-entry, you can show:
- Purchase: PAYMENT:xyz → WALLET:user (500 credits) ✓
- Consumption 1: WALLET:user → SERVICE:chat (50 credits) ✓
- Consumption 2: WALLET:user → SERVICE:image (50 credits) ✓
- Total: 500 - 100 = 400 credits ✓
Scenario 2: Revenue Reconciliation
CFO: "How much money is tied up in unused credits?"
Query:
SELECT SUM(amount) FROM credits_transaction
WHERE debitAccount LIKE 'PAYMENT:%'
AND userId IN (SELECT userId FROM credits_balance WHERE balance > 0)
Answer: $12,450 in unredeemed credit value
Scenario 3: System Integrity Check
// Daily integrity check
async function verifySystemIntegrity() {
// Sum all credits issued
const issued = await db
.select({ total: sum(creditsTransaction.amount) })
.where(like(creditsTransaction.debitAccount, 'PAYMENT:%'));
// Sum all user balances
const balances = await db
.select({ total: sum(creditsBalance.balance) });
// Sum all consumed credits
const consumed = await db
.select({ total: sum(creditsTransaction.amount) })
.where(like(creditsTransaction.creditAccount, 'SERVICE:%'));
// issued should equal balances + consumed
if (issued !== balances + consumed) {
throw new Error("Accounting mismatch detected!");
}
}
Design Trade-offs
Pros:
- ✅ Complete audit trail
- ✅ Fraud detection
- ✅ Financial accuracy
- ✅ Regulatory compliance
- ✅ Easy reporting
Cons:
- ⚠️ Slightly more complex implementation
- ⚠️ More database storage (worth it!)
- ⚠️ Requires understanding accounting concepts
Why Worth It:
Even small credit discrepancies can:
- Lead to user complaints and refunds
- Make it impossible to track down bugs
- Create tax and compliance issues
- Damage user trust
The added complexity is minimal compared to the benefits, especially for production systems handling real money.
💡 How It Works
1. Credit Grant (Registration Bonus Example)
When a user registers:
import { grantRegistrationBonus } from "@/credits/actions";
// Automatically called after user registration
await grantRegistrationBonus(userId);
What happens:
- Creates a new credit batch with 20 credits (configurable)
- Sets expiration date (30 days by default)
- Records transaction with type
registration_bonus
- Updates user's total balance
- Creates double-entry accounting records
2. Credit Consumption (FIFO)
When a service uses credits:
import { consumeCreditsForService } from "@/credits/actions";
// Consume credits for AI service
const result = await consumeCreditsForService({
userId: "user_123",
service: "google:chat", // Consumes 2 credits (configured)
});
// Or specify custom amount
const result = await consumeCreditsForService({
userId: "user_123",
service: "custom_service",
amount: 5, // Custom amount
description: "Custom AI model usage",
});
FIFO Process:
- Fetches all active batches ordered by expiration date
- Consumes from the oldest batch first
- If batch is depleted, moves to next batch
- Records detailed consumption log
- Updates batch status if fully consumed
- Creates transaction record
Example:
User has:
- Batch A: 10 credits (expires in 5 days)
- Batch B: 50 credits (expires in 25 days)
Service needs 15 credits:
→ Consume 10 from Batch A (now exhausted)
→ Consume 5 from Batch B (45 remaining)
3. Monthly Subscription Credits
For subscription plan users:
// Automatically called by cron job
import { processDailyGrant } from "@/credits/actions";
// Runs daily at 00:00 UTC
const results = await processDailyGrant();
Process:
- Finds all active subscriptions
- Checks subscription creation anniversary
- Verifies not already granted this month
- Grants monthly credits (e.g., 200 for Pro plan)
- Records with
grantPeriod
(YYYY-MM) - Prevents duplicate grants
4. Credit Expiration
Automated expiration handling:
// Automatically called by cron job
import { processExpiredCredits } from "@/credits/actions";
// Runs daily to process expired batches
const result = await processExpiredCredits();
Process:
- Finds all batches with
expiresAt < now()
- Updates batch status to
EXPIRED
- Creates expiration transaction
- Updates user balance
- Maintains accurate accounting
⚙️ Configuration
Main Configuration (src/config/index.ts
)
export const appConfig = {
credits: {
// Enable/disable entire credits system
enabled: true,
// Registration bonus settings
registration: {
enabled: true,
amount: 20, // Free credits for new users
validityDays: 30, // Expires in 30 days
},
// System limitations
limitations: {
allowFreeUserPurchasePackages: true, // Free users can buy credits
maxUserBalance: 10000, // Maximum credit limit
},
// Monthly credits for subscription plans
subscription: {
free: {
id: "free",
enabled: true,
monthlyGrant: 10, // 10 credits/month for free users
validityDays: 30,
},
pro: {
id: "pro",
enabled: true,
monthlyGrant: 200, // 200 credits/month for pro users
validityDays: 30,
},
},
// One-time credit packages
packages: {
lite: {
id: "lite",
name: "Lite",
credits: 100, // Base credits
priceId: process.env.CREDIT_LITE_PRICE_ID, // Stripe/Creem price ID
amount: 9.99, // USD price
currency: "USD",
validityDays: 90, // 3 months
bonus: 10, // Bonus credits
},
standard: {
id: "standard",
name: "Standard",
credits: 500,
priceId: process.env.CREDIT_STANDARD_PRICE_ID,
amount: 29.99,
currency: "USD",
validityDays: 90,
bonus: 50,
},
pro: {
id: "pro",
name: "Pro",
credits: 1500,
priceId: process.env.CREDIT_PRO_PRICE_ID,
amount: 79.99,
currency: "USD",
popular: true, // Highlight this package
validityDays: 90,
bonus: 200,
},
max: {
id: "max",
name: "Max",
credits: 5000,
priceId: process.env.CREDIT_MAX_PRICE_ID,
amount: 199.99,
currency: "USD",
validityDays: 365, // 1 year validity
bonus: 1000,
},
},
// Credit consumption rates for different services
consumption: {
"google:fast": 1, // Fast AI model: 1 credit
"google:chat": 2, // Chat model: 2 credits
"google:reasoning": 4, // Advanced reasoning: 4 credits
"google:image": 5, // Image generation: 5 credits
},
},
};
Design Philosophy
Why FIFO (First-In-First-Out)?
FIFO ensures fairness and encourages timely credit usage:
- Fair to users: Oldest credits are used first
- Encourages engagement: Users are motivated to use credits before expiration
- Predictable: Users understand which credits expire first
- Revenue optimization: Reduces unused credit accumulation
Why Expiration?
Credit expiration serves multiple purposes:
- Prevent hoarding: Encourages active usage
- Revenue recognition: Helps with financial planning
- User engagement: Creates urgency for platform use
- Fair monetization: Balances user value and business needs
Configuration Benefits
Different configurations yield different results:
Conservative Model (Longer expiration, lower grants):
registration: { amount: 10, validityDays: 14 },
subscription: {
pro: { monthlyGrant: 100, validityDays: 30 }
}
- Users buy more credit packages
- Higher conversion to paid plans
- More predictable usage patterns
Generous Model (Longer expiration, higher grants):
registration: { amount: 50, validityDays: 90 },
subscription: {
pro: { monthlyGrant: 500, validityDays: 60 }
}
- Better user experience
- Higher retention
- Encourages platform exploration
- Potential for viral growth
🚀 Getting Started
Step 1: Configure Environment Variables
Add to your .env
file:
# ---------Credits----------
# Create these products in Stripe/Creem dashboard first
CREDIT_LITE_PRICE_ID=price_xxx # Lite package price ID
CREDIT_STANDARD_PRICE_ID=price_xxx # Standard package price ID
CREDIT_PRO_PRICE_ID=price_xxx # Pro package price ID
CREDIT_MAX_PRICE_ID=price_xxx # Max package price ID
# ---------Cron Job----------
# Generate random 16-character secret
CRON_SECRET=your-secret-key-here
Step 2: Create Pricing in Payment Provider
For each credit package, create a one-time payment product in Stripe or Creem:
Stripe:
- Go to Stripe Dashboard → Products
- Click "Add product"
- Set pricing as one-time payment
- Copy the Price ID (starts with
price_
) - Add to environment variables
Creem:
- Go to Creem Dashboard → Products
- Create one-time product
- Copy the Product ID
- Add to environment variables
Step 3: Run Database Migrations
# Generate migration
pnpm db:generate
# Apply migration
pnpm db:migrate
Step 4: Set Up Cron Jobs
The system requires two cron jobs:
Grant Credits Cron (Daily)
URL: https://yourdomain.com/api/jobs/credits/grant
Schedule: Daily at 00:00 UTC
Header: Authorization: Bearer your-cron-secret
Expire Credits Cron (Daily)
URL: https://yourdomain.com/api/jobs/credits/expire
Schedule: Daily at 01:00 UTC
Header: Authorization: Bearer your-cron-secret
See Cron Job Setup Guide for detailed platform-specific instructions.
📖 Next Steps
- 📘 Cron Job Setup - Configure automated credit management
- 🔧 API Reference - Learn all credits functions
- 💡 Usage Examples - Real-world implementation examples
- 🎯 Best Practices - Optimize your credits strategy
🤔 Common Questions
Can I disable credits for certain plans?
Yes! Set enabled: false
in the subscription configuration:
subscription: {
free: {
enabled: false, // Free users don't get monthly credits
},
}
Can free users purchase credit packages?
Controlled by allowFreeUserPurchasePackages
:
limitations: {
allowFreeUserPurchasePackages: true, // Allow
}
What happens when credits expire?
- Batch status changes to
EXPIRED
- Credits are deducted from user balance
- Transaction record created for audit
- User is not notified (you can implement notifications)
How do I track credit usage?
Use the user transactions page or query directly:
import { getUserTransactions } from "@/credits/actions";
const { items } = await getUserTransactions({
userId: "user_123",
pageIndex: 0,
pageSize: 10,
});
📚 Related Documentation
- 💳 Payment Integration - Set up Stripe or Creem
- 🗄️ Database Schema - Understand data structure
- ⚙️ Configuration - App configuration guide