Claude-skill-registry credit-system
Implement and manage complex credit system with subscription credits, purchased credits, rollover logic, and atomic transactions. Use when working with credit deductions, refunds, subscriptions, and billing.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/credit-system" ~/.claude/skills/majiayu000-claude-skill-registry-credit-system && rm -rf "$T"
manifest:
skills/data/credit-system/SKILL.mdsource content
Credit System Implementation
Overview
The credit system handles two separate credit pools:
- Subscription Credits: Monthly allocation from subscription plans
- Purchased Credits: One-time purchases that never expire
Key Concepts
Credit Pools
- Subscription credits expire and rollover with caps
- Purchased credits are permanent
- Always deduct subscription credits first
Rollover Logic
- Unused subscription credits roll over to next cycle
- Capped based on plan (e.g., Pro caps at 200% of monthly allocation)
- Purchased credits don't count toward rollover caps
Atomic Operations
- All credit operations must be atomic
- Use database transactions to prevent race conditions
- Create audit trails for all transactions
Database Functions
Deduct Credits (Subscription First)
CREATE OR REPLACE FUNCTION deduct_credits( p_user_id UUID, p_amount INTEGER, p_reason TEXT DEFAULT 'image_generation' ) RETURNS TABLE( success BOOLEAN, subscription_used INTEGER, purchased_used INTEGER, new_balance_subscription INTEGER, new_balance_purchased INTEGER, transaction_id UUID ) LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE v_transaction_id UUID := gen_random_uuid(); v_subscription_balance INTEGER; v_purchased_balance INTEGER; v_subscription_to_deduct INTEGER; v_purchased_to_deduct INTEGER; BEGIN -- Lock user credits row to prevent race conditions PERFORM 1 FROM user_credits WHERE user_id = p_user_id FOR UPDATE; -- Get current balances SELECT subscription_credits, purchased_credits INTO v_subscription_balance, v_purchased_balance FROM user_credits WHERE user_id = p_user_id; -- Check if enough credits IF v_subscription_balance + v_purchased_balance < p_amount THEN RETURN QUERY SELECT false, 0, 0, v_subscription_balance, v_purchased_balance, v_transaction_id; RETURN; END IF; -- Calculate deduction: subscription first, then purchased v_subscription_to_deduct := LEAST(v_subscription_balance, p_amount); v_purchased_to_deduct := p_amount - v_subscription_to_deduct; -- Update balances UPDATE user_credits SET subscription_credits = subscription_credits - v_subscription_to_deduct, purchased_credits = purchased_credits - v_purchased_to_deduct, updated_at = now() WHERE user_id = p_user_id; -- Record transaction INSERT INTO credit_transactions ( id, user_id, amount, reason, subscription_credits_before, subscription_credits_after, purchased_credits_before, purchased_credits_after, created_at ) VALUES ( v_transaction_id, p_user_id, -p_amount, p_reason, v_subscription_balance, v_subscription_balance - v_subscription_to_deduct, v_purchased_balance, v_purchased_balance - v_purchased_to_deduct, now() ); RETURN QUERY SELECT true, v_subscription_to_deduct, v_purchased_to_deduct, v_subscription_balance - v_subscription_to_deduct, v_purchased_balance - v_purchased_to_deduct, v_transaction_id; END; $$;
Add Subscription Credits with Rollover
CREATE OR REPLACE FUNCTION add_subscription_credits( p_user_id UUID, p_amount INTEGER, p_rollover_cap INTEGER DEFAULT NULL, -- NULL = no cap p_reason TEXT DEFAULT 'monthly_grant' ) RETURNS TABLE( credits_added INTEGER, rollover_credits INTEGER, new_balance INTEGER, expired_credits INTEGER ) LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE v_current_balance INTEGER; v_current_rollover INTEGER; v_total_before INTEGER; v_total_after INTEGER; v_expired INTEGER := 0; v_rollover_to_add INTEGER := 0; v_credits_to_add INTEGER := p_amount; BEGIN -- Lock row PERFORM 1 FROM user_credits WHERE user_id = p_user_id FOR UPDATE; -- Get current state SELECT subscription_credits, rollover_credits, last_credit_reset INTO v_current_balance, v_current_rollover, last_credit_reset FROM user_credits WHERE user_id = p_user_id; -- Calculate total before v_total_before := v_current_balance + v_current_rollover; -- Handle expiration (if monthly reset) -- This would be called by a separate monthly reset function -- Add new credits v_current_balance := v_current_balance + p_amount; -- Handle rollover IF p_rollover_cap IS NOT NULL THEN -- Calculate what would be over cap IF v_current_balance > p_rollover_cap THEN v_rollover_to_add := v_current_balance - p_rollover_cap; v_current_balance := p_rollover_cap; END IF; END IF; -- Update database UPDATE user_credits SET subscription_credits = v_current_balance, rollover_credits = v_current_rollover + v_rollover_to_add, updated_at = now() WHERE user_id = p_user_id; -- Return results v_total_after := v_current_balance + v_current_rollover + v_rollover_to_add; RETURN QUERY SELECT v_credits_to_add, v_rollover_to_add, v_current_balance, v_expired; END; $$;
Refund Credits (Original Pool)
CREATE OR REPLACE FUNCTION refund_credits( p_transaction_id UUID, p_reason TEXT DEFAULT 'refund' ) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE v_user_id UUID; v_amount INTEGER; v_original_reason TEXT; v_subscription_used INTEGER; v_purchased_used INTEGER; BEGIN -- Get original transaction details SELECT user_id, amount, reason, subscription_credits_used, purchased_credits_used INTO v_user_id, v_amount, v_original_reason, v_subscription_used, v_purchased_used FROM credit_transactions WHERE id = p_transaction_id; -- Lock user credits PERFORM 1 FROM user_credits WHERE user_id = v_user_id FOR UPDATE; -- Refund to original pools UPDATE user_credits SET subscription_credits = subscription_credits + v_subscription_used, purchased_credits = purchased_credits + v_purchased_used, updated_at = now() WHERE user_id = v_user_id; -- Record refund transaction INSERT INTO credit_transactions ( user_id, amount, reason, subscription_credits_used, purchased_credits_used, refund_transaction_id, created_at ) VALUES ( v_user_id, v_amount, p_reason, v_subscription_used, v_purchased_used, p_transaction_id, now() ); RETURN true; END; $$;
Service Layer Patterns
Credit Transaction Service
export interface ICreditTransaction { id: string; userId: string; amount: number; reason: string; subscriptionCreditsUsed: number; purchasedCreditsUsed: number; createdAt: Date; } export interface ICreditDeductionRequest { userId: string; amount: number; reason?: string; } export interface ICreditDeductionResult { success: boolean; transactionId?: string; subscriptionUsed: number; purchasedUsed: number; newSubscriptionBalance: number; newPurchasedBalance: number; error?: string; } export class CreditTransactionService { /** * Deduct credits atomically with subscription-first logic */ static async deductCredits(request: ICreditDeductionRequest): Promise<ICreditDeductionResult> { const { userId, amount, reason = 'usage' } = request; // Validate input if (amount <= 0) { return { success: false, subscriptionUsed: 0, purchasedUsed: 0, newSubscriptionBalance: 0, newPurchasedBalance: 0, error: 'Amount must be positive', }; } try { // Use database transaction for atomicity const result = await supabase.rpc('deduct_credits', { p_user_id: userId, p_amount: amount, p_reason: reason, }); if (result.error) { throw new Error(result.error.message); } const data = result.data[0]; return { success: data.success, transactionId: data.transaction_id, subscriptionUsed: data.subscription_used, purchasedUsed: data.purchased_used, newSubscriptionBalance: data.new_balance_subscription, newPurchasedBalance: data.new_balance_purchased, error: data.success ? undefined : 'Insufficient credits', }; } catch (error) { return { success: false, subscriptionUsed: 0, purchasedUsed: 0, newSubscriptionBalance: 0, newPurchasedBalance: 0, error: error.message, }; } } /** * Refund credits to original pools */ static async refundCredits(transactionId: string, reason?: string): Promise<boolean> { try { const result = await supabase.rpc('refund_credits', { p_transaction_id: transactionId, p_reason: reason || 'refund', }); return !result.error && result.data; } catch (error) { console.error('Refund failed:', error); return false; } } /** * Get credit balance for user */ static async getBalance(userId: string): Promise<{ subscription: number; purchased: number; total: number; }> { const { data, error } = await supabase .from('user_credits') .select('subscription_credits, purchased_credits') .eq('user_id', userId) .single(); if (error || !data) { return { subscription: 0, purchased: 0, total: 0 }; } return { subscription: data.subscription_credits, purchased: data.purchased_credits, total: data.subscription_credits + data.purchased_credits, }; } }
Subscription Credit Service
export interface ISubscriptionTier { id: string; creditsPerCycle: number; rolloverCapPercentage: number; // e.g., 200 = 200% of monthly } export class SubscriptionCreditService { /** * Calculate rollover cap for tier */ static calculateRolloverCap(tier: ISubscriptionTier): number { return Math.floor(tier.creditsPerCycle * (tier.rolloverCapPercentage / 100)); } /** * Process monthly credit renewal */ static async processMonthlyRenewal( userId: string, tier: ISubscriptionTier ): Promise<{ added: number; rollover: number; expired: number; total: number; }> { const rolloverCap = this.calculateRolloverCap(tier); // Reset and add new credits with rollover logic const result = await supabase.rpc('add_subscription_credits', { p_user_id: userId, p_amount: tier.creditsPerCycle, p_rollover_cap: rolloverCap, p_reason: 'monthly_renewal', }); if (result.error) { throw new Error(result.error.message); } const data = result.data[0]; // Record audit trail await this.recordRenewal(userId, tier, data); return { added: data.credits_added, rollover: data.rollover_credits, expired: data.expired_credits, total: data.new_balance, }; } /** * Handle upgrade credits (use SubscriptionCreditsService) */ static async handleUpgrade( userId: string, previousTier: ISubscriptionTier, newTier: ISubscriptionTier, currentBalance: number ): Promise<number> { // Use the existing SubscriptionCreditsService for calculations const { SubscriptionCreditsService } = await import('./SubscriptionCredits'); const calculation = SubscriptionCreditsService.calculateUpgradeCredits({ currentBalance, previousTierCredits: previousTier.creditsPerCycle, newTierCredits: newTier.creditsPerCycle, }); if (calculation.creditsToAdd > 0) { await supabase.rpc('add_subscription_credits', { p_user_id: userId, p_amount: calculation.creditsToAdd, p_reason: 'upgrade_bonus', }); } return calculation.creditsToAdd; } private static async recordRenewal( userId: string, tier: ISubscriptionTier, data: any ): Promise<void> { // Create audit record await supabase.from('credit_renewals').insert({ user_id: userId, tier_id: tier.id, credits_added: data.credits_added, rollover_credits: data.rollover_credits, expired_credits: data.expired_credits, new_balance: data.new_balance, }); } }
Testing Patterns
Unit Tests
describe('CreditTransactionService', () => { describe('deductCredits', () => { it('should deduct subscription credits first', async () => { // Setup: User has 50 subscription, 30 purchased // Test: Deduct 60 credits // Expect: 50 subscription used, 10 purchased used }); it('should fail with insufficient credits', async () => { // Setup: User has 10 credits total // Test: Deduct 20 credits // Expect: Failure with error message }); it('should handle concurrent requests safely', async () => { // Test: Multiple concurrent deductions // Expect: No race conditions, atomic operations }); }); describe('refundCredits', () => { it('should refund to original pools', async () => { // Setup: Deducted 40 credits (30 subscription, 10 purchased) // Test: Refund transaction // Expect: 30 subscription restored, 10 purchased restored }); }); });
Integration Tests
describe('Credit System Integration', () => { it('should handle full credit lifecycle', async () => { // 1. Grant monthly credits // 2. Add purchased credits // 3. Deduct credits (subscription first) // 4. Process monthly renewal with rollover // 5. Verify all balances and audit trail }); });
Best Practices
Preventing Race Conditions
- Always use
to lock rowsSELECT ... FOR UPDATE - Keep transactions short and focused
- Use database functions for atomic operations
- Implement proper retry logic
Audit Trail
- Record every credit movement
- Link refunds to original transactions
- Store before/after balances
- Include reason codes
Performance
- Index user_credits.user_id
- Batch credit operations when possible
- Use materialized views for reporting
- Cache frequently accessed balances
Error Handling
- Validate all inputs
- Use explicit transactions
- Log all failures
- Implement circuit breakers for external dependencies
Monitoring
Key Metrics
- Credit deduction success rate
- Average transaction time
- Credit pool distribution
- Rollover utilization
- Refund rate by reason
Alerts
- Failed credit transactions
- Unusual credit balance changes
- High refund rates
- Database lock timeouts