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.
$ 安裝
git clone https://github.com/majiayu000/claude-skill-registry /tmp/claude-skill-registry && cp -r /tmp/claude-skill-registry/skills/development/credit-system ~/.claude/skills/claude-skill-registry// tip: Run this command in your terminal to install the skill
SKILL.md
name: credit-system description: 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.
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
SELECT ... FOR UPDATEto lock rows - 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
Repository

majiayu000
Author
majiayu000/claude-skill-registry/skills/development/credit-system
0
Stars
0
Forks
Updated21h ago
Added1w ago