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:
2025-12-18 15:16:44 +01:00
parent f83243fd90
commit 42b4b2b2e1
5 changed files with 773 additions and 0 deletions

View 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' };
}
}