Credits System Configuration
Configure credit packs and how AI features spend them
Credits are a pay-as-you-go balance that runs next to subscriptions. A user can buy a pack once and spend it on AI chat and image generation, or keep a subscription and a credit balance at the same time.
Set up a payment provider in the payment guide before you add credit packs. WeChat Pay, Stripe, and Creem grant credits after a successful payment when the plan's duration.type is 'credits'.
Related pages
| Page | Path | Description |
|---|---|---|
| Pricing | /pricing | Buy credits. Packs are the plans whose duration.type is 'credits'. |
| Dashboard | /dashboard | Balance and spend history |
| AI chat | /ai | Spends credits on chat |
| Image generation | /image-generate | Spends credits on images |
| Admin credits | /admin/credits | All credit transactions |
How it fits together
| Billing | One-time pack. Credits do not expire in the sample copy. |
| Providers that grant credits | WeChat Pay, Stripe, Creem |
| Chat spend | consumptionMode in config/credits.ts: 'fixed' or 'dynamic' |
| Image spend | Always the fixedConsumption.aiImage table |
| Subscriptions | Independent. A user can hold both. |
The shipped config/payment.ts only includes lifetime plans. Add credit packs on paymentConfig.plans. The pricing page splits that object by duration.type.
Credit packs
A credit pack is a normal payment plan. duration.type is 'credits', and credits is a sibling field (the number added to the balance). CreditPlan in config/types.ts also allows stripePriceId, stripeProductId, creemProductId, and dodoProductId.
// config/types.ts
export type CreditPlan = BasePlan & {
duration: { type: 'credits' };
credits: number;
stripePriceId?: string;
stripeProductId?: string;
creemProductId?: string;
dodoProductId?: string;
};Add packs beside the other plans in config/payment.ts. For Stripe, create a one-time Price and paste its id into stripePriceId. Creem uses creemProductId. WeChat Pay does not need a product id.
// config/payment.ts — inside paymentConfig.plans
credits100: {
provider: 'stripe',
id: 'credits100',
amount: 10,
currency: 'USD',
duration: {
type: 'credits',
},
credits: 100,
stripePriceId: 'price_xxx',
i18n: {
'en': {
name: '100 Credits',
description: 'Perfect for trying out AI features',
duration: '100 credits',
features: ['No expiration', 'Use anytime']
},
}
},
credits500: {
provider: 'stripe',
id: 'credits500',
amount: 45,
currency: 'USD',
recommended: true,
duration: {
type: 'credits',
},
credits: 550, // 500 plus a 50 credit bonus
stripePriceId: 'price_xxx',
i18n: {
'en': {
name: '500 Credits + 50 Bonus',
description: 'Best value for regular users',
duration: '550 credits',
features: ['10% bonus credits', 'No expiration']
},
}
},
credits1000: {
provider: 'stripe',
id: 'credits1000',
amount: 80,
currency: 'USD',
duration: {
type: 'credits',
},
credits: 1200, // 1000 plus a 200 credit bonus
stripePriceId: 'price_xxx',
i18n: {
'en': {
name: '1000 Credits + 200 Bonus',
description: 'Best for power users',
duration: '1200 credits',
features: ['20% bonus credits', 'No expiration']
},
}
},After payment, the Stripe, Creem, and WeChat webhook handlers call creditService.addCredits with amount: plan.credits and type: 'purchase'.
Consumption
config/credits.ts is the consumption config. It is not read from environment variables. The shipped mode is 'dynamic'.
Chat goes through calculateCreditConsumption:
'fixed'usesfixedConsumption.aiChat(a number, or{ default, models }resolved withresolveFixedConsumption).'dynamic'uses token usage:max(1, ceil((totalTokens / tokensPerCredit) * multiplier)).
Image generation goes through calculateImageCreditCost in libs/ai and always uses fixedConsumption.aiImage, including when chat is in dynamic mode. Model names are global, so image costs are a flat map.
// config/credits.ts
export const creditsConfig = {
consumptionMode: 'dynamic' as 'fixed' | 'dynamic',
fixedConsumption: {
aiChat: 1,
aiImage: {
default: 10,
models: {
'qwen-image-max': 8,
'qwen-image-plus': 5,
'fal-ai/flux/schnell': 6,
'fal-ai/flux/dev': 10,
'fal-ai/flux-pro': 12,
'fal-ai/qwen-image-2512/lora': 8,
'fal-ai/nano-banana-pro': 6,
'dall-e-3': 15,
'dall-e-2': 8,
},
},
},
dynamicConsumption: {
tokensPerCredit: 1000, // 1000 tokens = 1 credit before the multiplier
modelMultipliers: {
'gpt-4': 2.0,
'gpt-4-turbo': 1.5,
'qwen-max': 1.2,
'gpt-3.5-turbo': 1.0,
'qwen-plus': 1.0,
'deepseek-chat': 0.8,
'qwen-turbo': 0.5,
'default': 1.0,
},
},
}A model missing from modelMultipliers uses 'default' (1.0). The chat model list in config/ai.ts includes gpt-5, which is not in this map, so it uses that default until you add a row.
| Model | Multiplier |
|---|---|
| gpt-4 | 2.0 |
| gpt-4-turbo | 1.5 |
| qwen-max | 1.2 |
| gpt-3.5-turbo | 1.0 |
| qwen-plus | 1.0 |
| deepseek-chat | 0.8 |
| qwen-turbo | 0.5 |
| default | 1.0 |
HTTP API
These routes need a signed-in session. An anonymous request gets 401.
Balance
GET /api/credits/balance
{ "balance": 450 }Transactions
GET /api/credits/transactions?page=1&limit=10
Optional type: purchase, consumption, refund, bonus, or adjustment. limit is capped at 100.
{
"transactions": [
{
"id": "txn_xxx",
"userId": "user_xxx",
"type": "consumption",
"amount": "-1",
"balance": "449",
"orderId": null,
"description": "ai_chat",
"metadata": {
"provider": "openai",
"model": "gpt-5",
"inputTokens": 120,
"outputTokens": 380,
"totalTokens": 500
},
"createdAt": "2026-01-15T10:30:00.000Z"
}
],
"total": 25,
"page": 1,
"pageSize": 10,
"totalPages": 3
}amount and balance are numeric strings. Consumption amounts are negative. balance is the snapshot after that transaction.
Status
GET /api/credits/status
{
"credits": {
"balance": 450,
"totalPurchased": 550,
"totalConsumed": 100
},
"hasSubscription": false,
"canAccess": true
}canAccess is true when the user has a subscription or a balance above zero. totalPurchased sums purchase and bonus transactions.
Where credits are spent
AI chat. The /api/chat route checks the balance, streams the reply, then calls calculateCreditConsumption and creditService.consumeCredits. Descriptions use TransactionTypeCode.AI_CHAT (ai_chat). Token counts live in metadata.
Image generation. /api/image-generate prices the model with calculateImageCreditCost, consumes credits before the provider call, and refunds with addCredits({ type: 'refund' }) if generation fails.
Your own features. Call the same service for document work, transcription, or anything else you want to meter. TransactionTypeCode includes document_processing for that.
Using @libs/credits
import {
creditService,
calculateCreditConsumption,
safeNumber,
TransactionTypeCode,
} from '@libs/credits';
const balance = await creditService.getBalance(userId);
const allowed = await creditService.hasEnoughCredits(userId, 5);
// Payment webhooks do this after a credit pack is paid.
await creditService.addCredits({
userId,
amount: 100,
type: 'purchase', // 'purchase' | 'bonus' | 'refund' | 'adjustment'
orderId,
description: TransactionTypeCode.PURCHASE,
metadata: { planId: 'credits100' },
});
const totalTokens = safeNumber(usage.totalTokens);
const amount = calculateCreditConsumption({
totalTokens,
model: 'gpt-5',
provider: 'openai',
// type: 'aiChat' | 'aiImage' — only used when consumptionMode is 'fixed'
});
const result = await creditService.consumeCredits({
userId,
amount,
description: TransactionTypeCode.AI_CHAT,
metadata: { provider: 'openai', model: 'gpt-5', totalTokens },
});
if (result.success) {
console.log(result.newBalance, result.transactionId);
}consumeCredits deducts only when credit_balance >= amount, so two requests cannot overspend the same balance. On failure, result.success is false and result.error is set (Insufficient credits is the usual case).
safeNumber turns NaN, Infinity, and other non-finite values into 0 before you calculate a charge.
Other helpers on the same import: getFixedConsumptionAmount, isDynamicMode, getModelMultiplier, creditService.getTransactions, creditService.getTransactionsPaginated, and creditService.getStatus.
Transaction type codes
const TransactionTypeCode = {
AI_CHAT: 'ai_chat',
AI_IMAGE_GENERATION: 'ai_image_generation',
IMAGE_GENERATION: 'image_generation',
DOCUMENT_PROCESSING: 'document_processing',
PURCHASE: 'purchase',
BONUS: 'bonus',
REFUND: 'refund',
ADJUSTMENT: 'adjustment',
};Stored transaction types (credit_transaction.type) are purchase, consumption, refund, bonus, and adjustment. Purchase, bonus, refund, and positive adjustments increase the balance. Consumption is stored as a negative amount.
Related docs: