The credit system manages user credits across different subscription tiers, handling credit allocation, usage, rollover, and expiration. This document outlines the implementation details, key components, and common scenarios.
-
Free Tier
- 10 credits monthly
- No rollover support
- Credits reset monthly via cron job
- Expires after 30 days (renewable)
-
Paid Tiers
- Customizable credit limits
- Optional rollover support
- Credits refresh on billing cycle
- Supports credit accumulation up to maximum limit
- Allocation: Credits are assigned based on subscription tier
- Usage: Credits are deducted for specific actions
- Rollover: Unused credits can carry over (paid tiers only)
- Reset: Credits refresh on billing cycle or monthly (free tier)
-- subscription table credit fields
credits_remaining INTEGER -- Current available credits
credits_limit INTEGER -- Maximum allowed credits
credits_reset_count INTEGER -- Number of times credits have been reset
- Free Tier Refresh: Daily check for expired free subscriptions
- Updates end_date
- Resets credits to monthly allocation
- Updates reset counter
- Logs refresh action
- checkout.session.completed: Initial credit allocation
- customer.subscription.updated: Handles plan changes and credit adjustments
- invoice.paid: Manages credit renewal and rollover
// Free Tier Initial State
{
credits_remaining: 10,
credits_limit: 10,
credits_reset_count: 0
}
// Paid Tier Initial State
{
credits_remaining: plan.credits.monthly,
credits_limit: plan.credits.maximum,
credits_reset_count: 0
}
// Example: Standard Plan (1000 monthly, 3000 maximum)
// Current: 800 remaining
// After renewal: Math.min(800 + 1000, 3000) = 1800 credits
-- Monthly refresh via cron job
UPDATE subscription SET
credits_remaining = 10,
end_date = CURRENT_DATE + INTERVAL '1 month',
credits_reset_count = credits_reset_count + 1
WHERE plan_id = 'free' AND NOT cancelled;
// Example credit check
async function checkCredits(userId: string, requiredCredits: number) {
const { data: subscription } = await supabase
.from('subscription')
.select('*')
.eq('user_id', userId)
.single();
if (!subscription || subscription.credits_remaining < requiredCredits) {
throw new Error('Insufficient credits');
}
}
// Example credit deduction
async function useCredits(userId: string, credits: number) {
const { error } = await supabase
.from('subscription')
.update({
credits_remaining: sql`credits_remaining - ${credits}`
})
.eq('user_id', userId)
.gt('credits_remaining', credits - 1);
if (error) throw new Error('Failed to use credits');
}
-
Row Level Security (RLS)
- Users can only access their own subscription data
- Credit operations are protected by RLS policies
-
Atomic Operations
- Credit deductions use SQL operations to prevent race conditions
- Transactions used for critical credit operations
-
Audit Trail
- Credit operations are logged in subscription_audit_log
- Tracks credit refreshes, usage, and adjustments
The system uses Clerk as the primary authentication provider, while Supabase serves as our database with Row Level Security (RLS). This architecture requires specific considerations for database access.
- Service Role Access
- All database operations are performed using the Supabase service role
- This ensures consistent access regardless of authentication state
- Maintains compatibility between Clerk's auth tokens and Supabase's RLS
Our application uses Clerk for authentication, which means:
- User IDs are in Clerk's format (e.g.,
user_2qL1Z3kmB...
) - Database operations are authenticated via Clerk's session
- RLS policies are designed around service role access
This approach:
- Ensures reliable database access
- Maintains security through application-level auth
- Simplifies database operations
- Prevents auth-related edge cases
-
Always use service role for database operations
// Correct approach const supabase = createClient(cookieStore);
-
Validate user session at the application level
// Example: Protected API route const { userId } = auth(); if (!userId) throw new Error('Unauthorized');
-
Maintain audit trail for all operations
// Log important operations await supabase.from('subscription_audit_log').insert({ subscription_id, action, details });
- Update
subscription-prices.ts
with new credit values - Adjust cron job refresh amount if changing free tier
- Update maximum limits in database constraints
- Remove cron job:
SELECT cron.unschedule('refresh-free-subscriptions');
- Update free tier messaging to indicate one-time usage
- Implement expiration checks in credit usage logic
-
Credits Not Refreshing
- Check cron job status
- Verify webhook endpoints
- Check subscription dates
-
Rollover Not Working
- Confirm plan supports rollover
- Check maximum credit limits
- Verify webhook processing
-
Credit Deduction Failures
- Check RLS policies
- Verify sufficient credit balance
- Check transaction logs
-
Free Tier
- New subscription allocation
- Monthly refresh
- Expiration handling
-
Paid Tier
- Credit rollover
- Plan upgrade/downgrade
- Maximum limit enforcement
-
Error Cases
- Insufficient credits
- Invalid operations
- Concurrent usage
Use the provided test scenarios to verify system behavior.