Implement complete backend subscription system that limits free users to 10 AI conversations per month and offers Premium tier ($10/month or $100/year) with unlimited conversations. Changes: - Add User subscription fields (tier, status, limits, counters) - Create Subscription model to track Stripe subscriptions - Implement conversation limit enforcement in chat API - Add subscription checkout and customer portal APIs - Update Stripe webhook to handle subscription events - Add subscription utility functions (limit checks, tier management) - Add comprehensive subscription translations (en, ro, es, it) - Update environment variables for Stripe price IDs - Update footer "Sponsor Us" link to point to /donate - Add "Sponsor Us" button to home page hero section Database: - User model: subscriptionTier, subscriptionStatus, conversationLimit, conversationCount, limitResetDate, stripeCustomerId, stripeSubscriptionId - Subscription model: tracks Stripe subscription details, periods, status - SubscriptionStatus enum: ACTIVE, CANCELLED, PAST_DUE, TRIALING, etc. API Routes: - POST /api/subscriptions/checkout - Create Stripe checkout session - POST /api/subscriptions/portal - Get customer portal link - Webhook handlers for: customer.subscription.created/updated/deleted, invoice.payment_succeeded/failed Features: - Free tier: 10 conversations/month with automatic monthly reset - Premium tier: Unlimited conversations - Automatic limit enforcement before conversation creation - Returns LIMIT_REACHED error with upgrade URL when limit hit - Stripe Customer Portal integration for subscription management - Automatic tier upgrade/downgrade via webhooks Documentation: - SUBSCRIPTION_IMPLEMENTATION_PLAN.md - Complete implementation plan - SUBSCRIPTION_IMPLEMENTATION_STATUS.md - Current status and next steps Frontend UI still needed: subscription page, upgrade modal, usage display 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
149 lines
3.8 KiB
TypeScript
149 lines
3.8 KiB
TypeScript
import { prisma } from '@/lib/db'
|
|
|
|
export const SUBSCRIPTION_LIMITS = {
|
|
free: 10,
|
|
premium: 999999 // Effectively unlimited
|
|
}
|
|
|
|
export const STRIPE_PRICES = {
|
|
premium_monthly: process.env.STRIPE_PREMIUM_MONTHLY_PRICE_ID || '',
|
|
premium_yearly: process.env.STRIPE_PREMIUM_YEARLY_PRICE_ID || ''
|
|
}
|
|
|
|
export interface ConversationLimitCheck {
|
|
allowed: boolean
|
|
remaining: number
|
|
limit: number
|
|
tier: string
|
|
resetDate: Date | null
|
|
}
|
|
|
|
/**
|
|
* Check if user can create a new conversation
|
|
* Handles limit checking and automatic monthly reset
|
|
*/
|
|
export async function checkConversationLimit(userId: string): Promise<ConversationLimitCheck> {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
subscriptionTier: true,
|
|
conversationCount: true,
|
|
conversationLimit: true,
|
|
limitResetDate: true,
|
|
subscriptionStatus: true
|
|
}
|
|
})
|
|
|
|
if (!user) {
|
|
throw new Error('User not found')
|
|
}
|
|
|
|
// Reset counter if period expired
|
|
const now = new Date()
|
|
if (user.limitResetDate && now > user.limitResetDate) {
|
|
const nextReset = new Date(user.limitResetDate)
|
|
nextReset.setMonth(nextReset.getMonth() + 1)
|
|
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
conversationCount: 0,
|
|
limitResetDate: nextReset
|
|
}
|
|
})
|
|
user.conversationCount = 0
|
|
}
|
|
|
|
// Calculate remaining conversations
|
|
const remaining = user.conversationLimit - user.conversationCount
|
|
|
|
// Premium users always have access (unless subscription is past_due or cancelled)
|
|
const isPremiumActive = user.subscriptionTier === 'premium' &&
|
|
(user.subscriptionStatus === 'active' || user.subscriptionStatus === 'trialing')
|
|
|
|
const allowed = isPremiumActive || remaining > 0
|
|
|
|
return {
|
|
allowed,
|
|
remaining: isPremiumActive ? Infinity : Math.max(0, remaining),
|
|
limit: user.conversationLimit,
|
|
tier: user.subscriptionTier,
|
|
resetDate: user.limitResetDate
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Increment user's conversation count
|
|
*/
|
|
export async function incrementConversationCount(userId: string): Promise<void> {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { limitResetDate: true }
|
|
})
|
|
|
|
if (!user) {
|
|
throw new Error('User not found')
|
|
}
|
|
|
|
// Set initial reset date if not set (1 month from now)
|
|
const now = new Date()
|
|
const limitResetDate = user.limitResetDate || new Date(now.setMonth(now.getMonth() + 1))
|
|
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
conversationCount: { increment: 1 },
|
|
limitResetDate
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get subscription tier from Stripe price ID
|
|
*/
|
|
export function getTierFromPriceId(priceId: string): string {
|
|
if (priceId === STRIPE_PRICES.premium_monthly || priceId === STRIPE_PRICES.premium_yearly) {
|
|
return 'premium'
|
|
}
|
|
return 'free'
|
|
}
|
|
|
|
/**
|
|
* Get billing interval from Stripe price ID
|
|
*/
|
|
export function getIntervalFromPriceId(priceId: string): string {
|
|
if (priceId === STRIPE_PRICES.premium_yearly) return 'year'
|
|
return 'month'
|
|
}
|
|
|
|
/**
|
|
* Get conversation limit for a tier
|
|
*/
|
|
export function getLimitForTier(tier: string): number {
|
|
return SUBSCRIPTION_LIMITS[tier as keyof typeof SUBSCRIPTION_LIMITS] || SUBSCRIPTION_LIMITS.free
|
|
}
|
|
|
|
/**
|
|
* Format subscription status for display
|
|
*/
|
|
export function formatSubscriptionStatus(status: string): string {
|
|
const statusMap: Record<string, string> = {
|
|
'active': 'Active',
|
|
'cancelled': 'Cancelled',
|
|
'past_due': 'Past Due',
|
|
'trialing': 'Trial',
|
|
'incomplete': 'Incomplete',
|
|
'incomplete_expired': 'Expired',
|
|
'unpaid': 'Unpaid',
|
|
'expired': 'Expired'
|
|
}
|
|
return statusMap[status.toLowerCase()] || status
|
|
}
|
|
|
|
/**
|
|
* Check if subscription status is considered "active" for access purposes
|
|
*/
|
|
export function isSubscriptionActive(status: string): boolean {
|
|
return ['active', 'trialing'].includes(status.toLowerCase())
|
|
}
|