Skip to main content

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

FieldTypeDescription
creditsnumberCurrent available credits (can be spent)
creditsUsednumberTotal credits consumed (lifetime counter, never decreases)
creditsGrantednumberTotal 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:

OperationCostImplementation
Audio Transcription1 credit per transcriptionapps/functions/http/routes/transcribe.js
Future: Image generationTBDNot implemented
Future: Text generationTBDNot 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:

  1. New user signup: Initial welcome credits
  2. Subscription purchase: Credits based on tier
  3. Subscription renewal: Monthly credit refill
  4. 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:

TierInitial GrantMonthly GrantTotal/Month
Free3 credits0 credits3 credits (one-time)
Basic10 credits10 credits10 credits/month
Pro50 credits50 credits50 credits/month
Staff1000 creditsN/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

TypeDescriptionExample Reason
grantCredits added to account"signup", "subscription_purchase", "manual_grant"
consumeCredits deducted for operation"transcription", "image_generation"
refundCredits 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:

  1. Credit consumption rate: Credits consumed per day/week/month
  2. Credit balance distribution: How many users have 0, 1-10, 10-50, 50+ credits
  3. Insufficient credit errors: Frequency of 402 responses
  4. 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 privileges
  • consumeCredits(): Atomic Firestore transaction
  • refundCredits(): 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:

  1. Credit packages: One-time credit purchases (e.g., $10 for 100 credits)
  2. Usage alerts: Notify users at 80% credit consumption
  3. Credit expiration: Set expiry dates for promotional credits
  4. Tiered pricing: Different operations cost different amounts based on resource usage
  5. Family/team pooling: Share credit pool across multiple users
  6. Credit history dashboard: UI for users to see credit usage over time
  7. Predictive alerts: ML to predict when user will run out based on usage patterns