Credit System
Quick links: Public API
Overview
The credit system tracks usage of AI-powered features like audio transcription (via OpenAI Whisper) and potential future AI operations. Credits are consumed per operation and are tied to user subscriptions.
Credits are separate from API rate limits:
- Rate limits: Protect against API abuse (requests per time window)
- Credits: Track consumption of expensive AI operations (cost-based)
Credit Storage
Credits are stored in the users/{uid} document:
{
uid: string,
email: string,
// Credit tracking
credits: number, // Remaining credits
creditsUsed: number, // Total lifetime credits consumed
creditsGranted: number, // Total lifetime credits granted
// Subscription (affects credit grants)
tier: 'free' | 'basic' | 'pro',
stripeCustomerId: string | null,
stripeSubscriptionId: string | null,
...
}
Field Meanings
| Field | Type | Description |
|---|---|---|
credits | number | Current available credits (can be spent) |
creditsUsed | number | Total credits consumed (lifetime counter, never decreases) |
creditsGranted | number | Total credits granted (lifetime counter, never decreases) |
Invariant: creditsGranted - creditsUsed = credits (approximately, may differ due to refunds)
Credit Costs
Different AI operations consume different amounts of credits:
| Operation | Cost | Implementation |
|---|---|---|
| Audio Transcription | 1 credit per transcription | apps/functions/http/routes/transcribe.js |
| Future: Image generation | TBD | Not implemented |
| Future: Text generation | TBD | Not implemented |
Transcription Cost Calculation
Currently, transcription costs are flat-rate regardless of audio duration:
const TRANSCRIPTION_COST = 1; // 1 credit per transcription
Why flat-rate?
- Simplicity: Easy for users to understand
- Predictability: No surprise costs
- OpenAI Whisper pricing: Already per-minute, we absorb variance
Future consideration: Duration-based pricing (e.g., 1 credit per 10 minutes)
Credit Operations
1. Checking Credits
Before expensive operations, check if user has sufficient credits:
import { checkCredits } from './utils/credits.js';
// Check if user has enough credits
const result = await checkCredits(userId, requiredCredits);
if (!result.sufficient) {
return res.status(402).json({
error: {
code: 'insufficient_credits',
message: `Insufficient credits. Required: ${requiredCredits}, Available: ${result.available}`,
required: requiredCredits,
available: result.available
}
});
}
2. Consuming Credits
After successful operation, atomically deduct credits:
import { consumeCredits } from './utils/credits.js';
try {
// Perform AI operation
const transcription = await openai.audio.transcriptions.create({
file: audioStream,
model: 'whisper-1'
});
// Deduct credits (atomic transaction)
await consumeCredits(userId, TRANSCRIPTION_COST, {
operation: 'transcription',
audioId: mediaId,
durationSeconds: audioDuration
});
return res.json({ transcription });
} catch (error) {
// Credits NOT consumed if operation fails
throw error;
}
3. Granting Credits
Credits are granted when:
- New user signup: Initial welcome credits
- Subscription purchase: Credits based on tier
- Subscription renewal: Monthly credit refill
- Manual grant: Admin/support action
import { grantCredits } from './utils/credits.js';
// Grant credits (e.g., on subscription purchase)
await grantCredits(userId, amount, {
reason: 'subscription_purchase',
tier: 'pro',
stripeSubscriptionId
});
4. Refunding Credits
If an operation fails after credits were consumed, refund them:
import { refundCredits } from './utils/credits.js';
// Refund credits for failed operation
await refundCredits(userId, amount, {
reason: 'operation_failed',
originalOperation: 'transcription',
audioId: mediaId
});
Credit Grants by Tier
Different subscription tiers receive different credit allotments:
| Tier | Initial Grant | Monthly Grant | Total/Month |
|---|---|---|---|
| Free | 3 credits | 0 credits | 3 credits (one-time) |
| Basic | 10 credits | 10 credits | 10 credits/month |
| Pro | 50 credits | 50 credits | 50 credits/month |
| Staff | 1000 credits | N/A (unlimited) | Effectively unlimited |
Grant Timing
Initial grant (on signup):
- Triggered by: User document creation
- Implementation:
apps/functions/triggers/user-lifecycle.js - One-time only
Monthly grant (subscription refill):
- Triggered by: Stripe webhook
invoice.payment_succeeded - Implementation:
apps/functions/webhooks/stripe.js - Adds to existing balance (does not reset)
Rollover policy: Credits roll over month-to-month (no expiration)
Credit Transactions
For audit purposes, all credit operations are logged to users/{uid}/creditTransactions subcollection:
Collection: users/{uid}/creditTransactions/{transactionId}
Document structure:
{
id: string, // Auto-generated transaction ID
timestamp: Timestamp, // When transaction occurred
// Transaction type
type: 'grant' | 'consume' | 'refund',
// Amount (always positive)
amount: number,
// Balance after transaction
balanceAfter: number,
// Context
reason: string, // Why this transaction happened
metadata: object, // Operation-specific details
// Trace context (if part of a request)
traceId: string | null,
flowId: string | null
}
Transaction Types
| Type | Description | Example Reason |
|---|---|---|
grant | Credits added to account | "signup", "subscription_purchase", "manual_grant" |
consume | Credits deducted for operation | "transcription", "image_generation" |
refund | Credits returned after failure | "operation_failed", "manual_refund" |
Example Transactions
Grant on signup:
{
type: 'grant',
amount: 3,
balanceAfter: 3,
reason: 'signup',
metadata: { tier: 'free' }
}
Consume for transcription:
{
type: 'consume',
amount: 1,
balanceAfter: 2,
reason: 'transcription',
metadata: {
audioId: 'media_abc123',
durationSeconds: 45,
modelUsed: 'whisper-1'
}
}
Refund for failed operation:
{
type: 'refund',
amount: 1,
balanceAfter: 3,
reason: 'operation_failed',
metadata: {
originalTransaction: 'txn_xyz789',
error: 'OpenAI API timeout'
}
}
API Integration
Insufficient Credits Response
When user attempts operation without enough credits:
Status: 402 Payment Required
Response body:
{
"error": {
"code": "insufficient_credits",
"message": "Insufficient credits. Required: 1, Available: 0. Please upgrade your subscription.",
"timestamp": "2025-01-15T10:30:00.000Z",
"requestId": "req_abc123",
"required": 1,
"available": 0
}
}
Success Response with Credit Usage
Operations that consume credits include credit info in response:
{
"transcription": {
"text": "This is the transcribed audio...",
"language": "en",
"duration": 45
},
"credits": {
"consumed": 1,
"remaining": 49
}
}
Subscription Integration
Credits are tightly integrated with Stripe subscriptions:
On Subscription Creation
// Stripe webhook: checkout.session.completed
export const handleCheckoutCompleted = async (session) => {
const { customer, subscription, metadata } = session;
const { uid, tier } = metadata;
// Link subscription to user
await db.collection('users').doc(uid).update({
tier,
stripeCustomerId: customer,
stripeSubscriptionId: subscription
});
// Grant initial subscription credits
const creditAmount = TIER_CREDITS[tier]; // e.g., pro: 50
await grantCredits(uid, creditAmount, {
reason: 'subscription_purchase',
tier,
stripeSubscriptionId: subscription
});
};
On Monthly Renewal
// Stripe webhook: invoice.payment_succeeded
export const handleInvoicePaid = async (invoice) => {
const { customer, subscription, billing_reason } = invoice;
// Only grant credits on subscription renewal (not first payment)
if (billing_reason !== 'subscription_cycle') return;
// Get user from customer ID
const user = await getUserByStripeCustomer(customer);
// Grant monthly credits
const creditAmount = TIER_CREDITS[user.tier];
await grantCredits(user.uid, creditAmount, {
reason: 'subscription_renewal',
tier: user.tier,
stripeSubscriptionId: subscription
});
};
On Subscription Cancellation
// Stripe webhook: customer.subscription.deleted
export const handleSubscriptionDeleted = async (subscription) => {
const { customer, id } = subscription;
// Get user from customer ID
const user = await getUserByStripeCustomer(customer);
// Downgrade to free tier
await db.collection('users').doc(user.uid).update({
tier: 'free',
stripeSubscriptionId: null
});
// Credits remain (rollover), but no new monthly grants
// User keeps existing credits until consumed
};
Monitoring & Analytics
Key Metrics
Track these metrics to understand credit system health:
- Credit consumption rate: Credits consumed per day/week/month
- Credit balance distribution: How many users have 0, 1-10, 10-50, 50+ credits
- Insufficient credit errors: Frequency of 402 responses
- Credit grant sources: How many credits from signup vs. subscription vs. manual
Queries
Credit consumption in last 30 days:
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const snapshot = await db.collectionGroup('creditTransactions')
.where('type', '==', 'consume')
.where('timestamp', '>=', thirtyDaysAgo)
.get();
const totalConsumed = snapshot.docs.reduce((sum, doc) => sum + doc.data().amount, 0);
Users with low credits (less than 3):
const lowCreditUsers = await db.collection('users')
.where('credits', '<', 3)
.where('tier', 'in', ['free', 'basic'])
.get();
console.log(`${lowCreditUsers.size} users with <3 credits`);
Most active users (by credit consumption):
// Aggregate creditTransactions by userId
const snapshot = await db.collectionGroup('creditTransactions')
.where('type', '==', 'consume')
.where('timestamp', '>=', monthStart)
.get();
const userConsumption = {};
snapshot.forEach(doc => {
const userId = doc.ref.parent.parent.id;
userConsumption[userId] = (userConsumption[userId] || 0) + doc.data().amount;
});
// Sort and get top 10
const topUsers = Object.entries(userConsumption)
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
Security
Client Access
Credits are stored in user documents, but clients should NOT have direct write access:
Firestore Security Rules:
match /users/{uid} {
allow read: if request.auth.uid == uid;
allow update: if request.auth.uid == uid
&& !request.resource.data.diff(resource.data).affectedKeys()
.hasAny(['credits', 'creditsUsed', 'creditsGranted', 'tier', 'stripeCustomerId', 'stripeSubscriptionId']);
}
match /users/{uid}/creditTransactions/{txnId} {
allow read: if request.auth.uid == uid;
allow write: if false; // Only server can write
}
This prevents users from:
- ❌ Granting themselves credits
- ❌ Modifying credit history
- ❌ Changing their subscription tier
- ✅ Reading their credit balance
- ✅ Reading their transaction history
Server-Side Enforcement
All credit operations MUST go through Cloud Functions:
grantCredits(): Uses Admin SDK with elevated privilegesconsumeCredits(): Atomic Firestore transactionrefundCredits(): Atomic Firestore transaction
Client cannot directly call Firestore to modify credits.
Best Practices
1. Check Before Consuming
Always verify credits BEFORE expensive operations:
// ✅ Good: Check first
const creditCheck = await checkCredits(userId, 1);
if (!creditCheck.sufficient) {
return res.status(402).json({ error: { code: 'insufficient_credits' } });
}
// Perform operation
const result = await expensiveAiOperation();
// Then consume
await consumeCredits(userId, 1);
// ❌ Bad: Consume without checking
await consumeCredits(userId, 1); // Might fail mid-operation
2. Use Transactions for Atomic Ops
Credit operations use Firestore transactions to prevent race conditions:
await db.runTransaction(async (transaction) => {
const userRef = db.collection('users').doc(userId);
const userDoc = await transaction.get(userRef);
const current = userDoc.data().credits;
// Check sufficient
if (current < amount) {
throw new Error('Insufficient credits');
}
// Atomic update
transaction.update(userRef, {
credits: current - amount,
creditsUsed: admin.firestore.FieldValue.increment(amount)
});
});
3. Log All Credit Operations
Always create transaction records:
// Create audit log
await db.collection('users').doc(userId)
.collection('creditTransactions').add({
type: 'consume',
amount: 1,
balanceAfter: newBalance,
reason: 'transcription',
metadata: { audioId, durationSeconds },
timestamp: admin.firestore.FieldValue.serverTimestamp()
});
4. Handle Failures Gracefully
If operation fails after credit check but before consumption:
let creditsConsumed = false;
try {
// Check credits
await checkCredits(userId, 1);
// Perform operation
const result = await expensiveOperation();
// Consume credits (mark as consumed)
await consumeCredits(userId, 1);
creditsConsumed = true;
return result;
} catch (error) {
// Refund if we consumed but operation failed
if (creditsConsumed) {
await refundCredits(userId, 1, { reason: 'operation_failed' });
}
throw error;
}
Future Enhancements
Potential improvements to consider:
- Credit packages: One-time credit purchases (e.g., $10 for 100 credits)
- Usage alerts: Notify users at 80% credit consumption
- Credit expiration: Set expiry dates for promotional credits
- Tiered pricing: Different operations cost different amounts based on resource usage
- Family/team pooling: Share credit pool across multiple users
- Credit history dashboard: UI for users to see credit usage over time
- Predictive alerts: ML to predict when user will run out based on usage patterns
Related Documentation
- Public API - API responses with credit information
- Rate Limiting - Separate from credits, request-based limits
- Tracing & Logging - Observability and debugging