LogoNEXTDEVKIT Docs

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:

  1. Flexible Monetization: Combine subscriptions with pay-as-you-go credits
  2. User Freedom: Let free users purchase credit packages without forcing subscriptions
  3. Predictable Revenue: Mix recurring and one-time revenue streams
  4. 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 credits
  • SYSTEM:registration-bonus - Welcome bonuses
  • PAYMENT:purchase_id - Purchased credits
  • SYSTEM:expiration - Expired credits cleanup

User Accounts (Credit Destinations):

  • WALLET:user_id - User credit balance
  • SERVICE: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:

  1. Creates a new credit batch with 20 credits (configurable)
  2. Sets expiration date (30 days by default)
  3. Records transaction with type registration_bonus
  4. Updates user's total balance
  5. 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:

  1. Fetches all active batches ordered by expiration date
  2. Consumes from the oldest batch first
  3. If batch is depleted, moves to next batch
  4. Records detailed consumption log
  5. Updates batch status if fully consumed
  6. 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:

  1. Finds all active subscriptions
  2. Checks subscription creation anniversary
  3. Verifies not already granted this month
  4. Grants monthly credits (e.g., 200 for Pro plan)
  5. Records with grantPeriod (YYYY-MM)
  6. 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:

  1. Finds all batches with expiresAt < now()
  2. Updates batch status to EXPIRED
  3. Creates expiration transaction
  4. Updates user balance
  5. 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:

  1. Go to Stripe Dashboard → Products
  2. Click "Add product"
  3. Set pricing as one-time payment
  4. Copy the Price ID (starts with price_)
  5. Add to environment variables

Creem:

  1. Go to Creem Dashboard → Products
  2. Create one-time product
  3. Copy the Product ID
  4. 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

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