feat: implement subscription plan system with monthly credits
- Database schema: * subscription_plans table - stores plan tiers (Starter, Bronze, Silver, Gold) * user_subscriptions table - assigns users to plans * Default plans created (10, 50, 100, 250 credits/month) * All existing users assigned to Starter plan - Subscription service (subscription-service.ts): * getAllPlans() - fetch all plans * getActivePlans() - fetch active plans for users * createPlan() - admin creates new plan * updatePlan() - admin edits plan * deletePlan() - admin removes plan * getUserSubscription() - get user's current plan * setUserPlan() - admin assigns user to plan * grantMonthlyCredits() - distribute credits to all users - Plan management interface (/admin/plans): * Visual plan cards with credits, price, description * Create/Edit/Delete plans * Toggle active/inactive status * Sort order management * Grant monthly credits button (manual trigger) - Features: * Monthly credit allocation based on plan * Prevents duplicate credit grants (tracks last_credit_grant_at) * Admin can manually trigger monthly credit distribution * Plans can be activated/deactivated * Custom pricing and credit amounts per plan - UI: * Beautiful plan cards with color coding * Modal for create/edit with validation * Success/error messages * Manage Plans button in admin dashboard Ready for future automation (cron job for monthly credits) and payment integration (Stripe/PayPal).
This commit is contained in:
322
src/services/subscription-service.ts
Normal file
322
src/services/subscription-service.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
'use server';
|
||||
|
||||
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { checkIsAdmin } from './track-api-usage';
|
||||
import { addCredits } from './credit-service';
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
monthly_credits: number;
|
||||
price: number;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserSubscription {
|
||||
user_id: string;
|
||||
plan_id: string | null;
|
||||
started_at: string;
|
||||
last_credit_grant_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscription plans
|
||||
*/
|
||||
export async function getAllPlans(): Promise<SubscriptionPlan[]> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subscription_plans')
|
||||
.select('*')
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching plans:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data || [];
|
||||
} catch (err) {
|
||||
console.error('Error in getAllPlans:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active subscription plans (for users to see)
|
||||
*/
|
||||
export async function getActivePlans(): Promise<SubscriptionPlan[]> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subscription_plans')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching active plans:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data || [];
|
||||
} catch (err) {
|
||||
console.error('Error in getActivePlans:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subscription plan (admin only)
|
||||
*/
|
||||
export async function createPlan(plan: Omit<SubscriptionPlan, 'id' | 'created_at' | 'updated_at'>): Promise<{ success: boolean; error?: string; plan?: SubscriptionPlan }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Check if current user is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: 'Not authenticated' };
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) return { success: false, error: 'Not authorized' };
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subscription_plans')
|
||||
.insert(plan)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating plan:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true, plan: data };
|
||||
} catch (err) {
|
||||
console.error('Error in createPlan:', err);
|
||||
return { success: false, error: 'Failed to create plan' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a subscription plan (admin only)
|
||||
*/
|
||||
export async function updatePlan(planId: string, updates: Partial<SubscriptionPlan>): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Check if current user is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: 'Not authenticated' };
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) return { success: false, error: 'Not authorized' };
|
||||
|
||||
const { error } = await supabase
|
||||
.from('subscription_plans')
|
||||
.update({ ...updates, updated_at: new Date().toISOString() })
|
||||
.eq('id', planId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating plan:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error in updatePlan:', err);
|
||||
return { success: false, error: 'Failed to update plan' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a subscription plan (admin only)
|
||||
*/
|
||||
export async function deletePlan(planId: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Check if current user is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: 'Not authenticated' };
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) return { success: false, error: 'Not authorized' };
|
||||
|
||||
const { error } = await supabase
|
||||
.from('subscription_plans')
|
||||
.delete()
|
||||
.eq('id', planId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting plan:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error in deletePlan:', err);
|
||||
return { success: false, error: 'Failed to delete plan' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's current subscription
|
||||
*/
|
||||
export async function getUserSubscription(userId: string): Promise<{ subscription: UserSubscription | null; plan: SubscriptionPlan | null }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
const { data: subscription, error: subError } = await supabase
|
||||
.from('user_subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (subError || !subscription) {
|
||||
return { subscription: null, plan: null };
|
||||
}
|
||||
|
||||
if (!subscription.plan_id) {
|
||||
return { subscription, plan: null };
|
||||
}
|
||||
|
||||
const { data: plan, error: planError } = await supabase
|
||||
.from('subscription_plans')
|
||||
.select('*')
|
||||
.eq('id', subscription.plan_id)
|
||||
.single();
|
||||
|
||||
if (planError) {
|
||||
return { subscription, plan: null };
|
||||
}
|
||||
|
||||
return { subscription, plan };
|
||||
} catch (err) {
|
||||
console.error('Error in getUserSubscription:', err);
|
||||
return { subscription: null, plan: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user's subscription plan (admin only)
|
||||
*/
|
||||
export async function setUserPlan(userId: string, planId: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Check if current user is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: 'Not authenticated' };
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) return { success: false, error: 'Not authorized' };
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_subscriptions')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
plan_id: planId,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error setting user plan:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error in setUserPlan:', err);
|
||||
return { success: false, error: 'Failed to set user plan' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant monthly credits to all users based on their subscription plan
|
||||
* This should be called by a cron job or manually by admin
|
||||
*/
|
||||
export async function grantMonthlyCredits(): Promise<{ success: boolean; processed: number; failed: number; error?: string }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Check if current user is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, processed: 0, failed: 0, error: 'Not authenticated' };
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) return { success: false, processed: 0, failed: 0, error: 'Not authorized' };
|
||||
|
||||
// Get all user subscriptions with their plans
|
||||
const { data: subscriptions, error: subError } = await supabase
|
||||
.from('user_subscriptions')
|
||||
.select(`
|
||||
*,
|
||||
plan:plan_id (
|
||||
id,
|
||||
display_name,
|
||||
monthly_credits
|
||||
)
|
||||
`);
|
||||
|
||||
if (subError) {
|
||||
console.error('Error fetching subscriptions:', subError);
|
||||
return { success: false, processed: 0, failed: 0, error: 'Failed to fetch subscriptions' };
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const now = new Date();
|
||||
const oneMonthAgo = new Date(now);
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
|
||||
for (const sub of subscriptions || []) {
|
||||
// Check if credits were already granted this month
|
||||
const lastGrant = new Date(sub.last_credit_grant_at);
|
||||
if (lastGrant > oneMonthAgo) {
|
||||
continue; // Already granted this month
|
||||
}
|
||||
|
||||
const plan = sub.plan as any;
|
||||
if (!plan || !plan.monthly_credits) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Grant credits
|
||||
const result = await addCredits(
|
||||
sub.user_id,
|
||||
plan.monthly_credits,
|
||||
`Monthly credits for ${plan.display_name} plan`,
|
||||
user.id
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// Update last_credit_grant_at
|
||||
await supabase
|
||||
.from('user_subscriptions')
|
||||
.update({ last_credit_grant_at: now.toISOString() })
|
||||
.eq('user_id', sub.user_id);
|
||||
|
||||
processed++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, processed, failed };
|
||||
} catch (err) {
|
||||
console.error('Error in grantMonthlyCredits:', err);
|
||||
return { success: false, processed: 0, failed: 0, error: 'Failed to grant monthly credits' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user