TinyShip
TinyShip
 TinyShip
TinyShip
TinyShip Documentation
TinyShip User GuideGetting StartedBasic Configuration
Credits System ConfigurationAffiliate System Configuration
Storage Service ConfigurationDatabase ConfigurationCaptcha Configuration
Development Best PracticesLocal E2E Workflow
User Guide

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

PagePathDescription
Pricing/pricingBuy credits. Packs are the plans whose duration.type is 'credits'.
Dashboard/dashboardBalance and spend history
AI chat/aiSpends credits on chat
Image generation/image-generateSpends credits on images
Admin credits/admin/creditsAll credit transactions

How it fits together

BillingOne-time pack. Credits do not expire in the sample copy.
Providers that grant creditsWeChat Pay, Stripe, Creem
Chat spendconsumptionMode in config/credits.ts: 'fixed' or 'dynamic'
Image spendAlways the fixedConsumption.aiImage table
SubscriptionsIndependent. 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' uses fixedConsumption.aiChat (a number, or { default, models } resolved with resolveFixedConsumption).
  • '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.

ModelMultiplier
gpt-42.0
gpt-4-turbo1.5
qwen-max1.2
gpt-3.5-turbo1.0
qwen-plus1.0
deepseek-chat0.8
qwen-turbo0.5
default1.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:

  • Payment configuration
  • Payment testing

Payment testing

Test payments and webhooks in local development

Affiliate System Configuration

Configure referrals, signup bonuses, commission records, and withdrawal approval

On this page

Related pagesHow it fits togetherCredit packsConsumptionHTTP APIBalanceTransactionsStatusWhere credits are spentUsing @libs/creditsTransaction type codes