feat: implement subscription system with conversation limits

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>
This commit is contained in:
2025-10-12 22:14:22 +00:00
parent be22b5b4fd
commit c3cd353f2f
16 changed files with 3771 additions and 699 deletions

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe-server'
import { prisma } from '@/lib/db'
import Stripe from 'stripe'
import { getTierFromPriceId, getIntervalFromPriceId, getLimitForTier } from '@/lib/subscription-utils'
export async function POST(req: NextRequest) {
const body = await req.text()
@@ -115,6 +116,134 @@ export async function POST(req: NextRequest) {
break
}
// Subscription events
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const stripeSubscription = event.data.object as Stripe.Subscription
const userId = stripeSubscription.metadata.userId
if (!userId) {
console.warn('⚠️ No userId in subscription metadata:', stripeSubscription.id)
break
}
const priceId = stripeSubscription.items.data[0]?.price.id
if (!priceId) {
console.warn('⚠️ No price ID in subscription:', stripeSubscription.id)
break
}
const tier = getTierFromPriceId(priceId)
const interval = getIntervalFromPriceId(priceId)
const limit = getLimitForTier(tier)
// Upsert subscription record
await prisma.subscription.upsert({
where: { stripeSubscriptionId: stripeSubscription.id },
create: {
userId,
stripeSubscriptionId: stripeSubscription.id,
stripePriceId: priceId,
stripeCustomerId: stripeSubscription.customer as string,
status: stripeSubscription.status.toUpperCase() as any,
currentPeriodStart: new Date((stripeSubscription as any).current_period_start * 1000),
currentPeriodEnd: new Date((stripeSubscription as any).current_period_end * 1000),
cancelAtPeriodEnd: (stripeSubscription as any).cancel_at_period_end,
tier,
interval
},
update: {
status: stripeSubscription.status.toUpperCase() as any,
currentPeriodStart: new Date((stripeSubscription as any).current_period_start * 1000),
currentPeriodEnd: new Date((stripeSubscription as any).current_period_end * 1000),
cancelAtPeriodEnd: (stripeSubscription as any).cancel_at_period_end,
stripePriceId: priceId
}
})
// Update user subscription tier and limit
await prisma.user.update({
where: { id: userId },
data: {
subscriptionTier: tier,
conversationLimit: limit,
subscriptionStatus: stripeSubscription.status,
stripeSubscriptionId: stripeSubscription.id,
stripeCustomerId: stripeSubscription.customer as string
}
})
console.log(`✅ Subscription ${stripeSubscription.status} for user ${userId} (tier: ${tier})`)
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
const sub = await prisma.subscription.findUnique({
where: { stripeSubscriptionId: subscription.id },
select: { userId: true }
})
if (sub) {
// Downgrade to free tier
await prisma.user.update({
where: { id: sub.userId },
data: {
subscriptionTier: 'free',
conversationLimit: 10,
subscriptionStatus: 'cancelled'
}
})
await prisma.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: { status: 'CANCELLED' }
})
console.log(`✅ Subscription cancelled for user ${sub.userId}`)
}
break
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as any
if (invoice.subscription) {
console.log(`✅ Payment succeeded for subscription ${invoice.subscription}`)
// Ensure subscription is still active
const subscription = await prisma.subscription.findUnique({
where: { stripeSubscriptionId: invoice.subscription as string }
})
if (subscription) {
await prisma.user.update({
where: { id: subscription.userId },
data: { subscriptionStatus: 'active' }
})
}
}
break
}
case 'invoice.payment_failed': {
const invoice = event.data.object as any
if (invoice.subscription) {
const subscription = await prisma.subscription.findUnique({
where: { stripeSubscriptionId: invoice.subscription as string }
})
if (subscription) {
await prisma.user.update({
where: { id: subscription.userId },
data: { subscriptionStatus: 'past_due' }
})
console.warn(`⚠️ Payment failed for subscription ${invoice.subscription}`)
}
}
break
}
default:
console.log(`Unhandled event type: ${event.type}`)
}