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>
19 KiB
User Subscription System - Implementation Plan
Overview
Implement a subscription-based model for Biblical Guide that limits AI chat conversations for free users and offers paid tiers with increased or unlimited access.
Current State Analysis
What EXISTS ✅
- Authentication system (JWT-based, required for chat)
- Chat conversation tracking in database
- User model with basic fields
- Stripe integration for one-time donations
- Stripe webhook handling (donations only)
What DOES NOT EXIST ❌
- User subscription system
- Subscription tiers/plans
- Conversation limits (free vs paid)
- Usage tracking/quota enforcement
- Upgrade prompts when limits reached
- Subscription management UI
- Stripe subscription integration (only donations exist)
Subscription Tiers
Free Tier
- Price: $0/month
- Conversations: 10 per month
- Features:
- Full Bible access
- Prayer wall access
- Bookmarks & highlights
- 10 AI conversations/month
- Reset: Monthly on signup anniversary
Premium Tier
- Price: $10/month (or $100/year with 17% discount)
- Conversations: Unlimited
- Features:
- Everything in Free
- Unlimited AI conversations
- Priority support
- Early access to new features
Donation System (Existing)
- One-time donations (separate from subscriptions)
- Recurring donations (separate from subscriptions)
- No perks attached to donations
Implementation Phases
Phase 1: Database Schema & Migrations
1.1 Update User Model
File: prisma/schema.prisma
Add to User model:
model User {
// ... existing fields ...
// Subscription fields
subscriptionTier String @default("free") // "free", "premium"
subscriptionStatus String @default("active") // "active", "cancelled", "expired", "past_due"
conversationLimit Int @default(10)
conversationCount Int @default(0) // Reset monthly
limitResetDate DateTime? // When to reset conversation count
stripeCustomerId String? @unique // For subscriptions (not donations)
stripeSubscriptionId String? @unique
// Relations
subscriptions Subscription[]
}
1.2 Create Subscription Model
model Subscription {
id String @id @default(uuid())
userId String
stripeSubscriptionId String @unique
stripePriceId String // Stripe price ID for the plan
stripeCustomerId String
status SubscriptionStatus
currentPeriodStart DateTime
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
tier String // "premium"
interval String // "month" or "year"
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([status])
@@index([stripeSubscriptionId])
}
enum SubscriptionStatus {
ACTIVE
CANCELLED
PAST_DUE
TRIALING
INCOMPLETE
INCOMPLETE_EXPIRED
UNPAID
}
1.3 Run Migration
npx prisma migrate dev --name add_subscription_system
npx prisma generate
Phase 2: Conversation Limit Enforcement
2.1 Update Chat API Route
File: app/api/chat/route.ts
Add conversation limit check before processing:
// Add after authentication check (line 58)
// Check conversation limits for authenticated users
if (userId && !conversationId) {
// Only check limits when creating NEW conversation
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
subscriptionTier: true,
conversationCount: true,
conversationLimit: true,
limitResetDate: true,
subscriptionStatus: true
}
})
if (!user) {
return NextResponse.json({
success: false,
error: 'User not found',
code: 'USER_NOT_FOUND'
}, { status: 404 })
}
// Reset counter if period expired
const now = new Date()
if (user.limitResetDate && now > user.limitResetDate) {
// Reset monthly counter
const nextResetDate = new Date(user.limitResetDate)
nextResetDate.setMonth(nextResetDate.getMonth() + 1)
await prisma.user.update({
where: { id: userId },
data: {
conversationCount: 0,
limitResetDate: nextResetDate
}
})
user.conversationCount = 0
}
// Check if user has exceeded limit (only for free tier with active status)
if (user.subscriptionTier === 'free' && user.subscriptionStatus === 'active') {
if (user.conversationCount >= user.conversationLimit) {
return NextResponse.json({
success: false,
error: 'Conversation limit reached. Upgrade to Premium for unlimited conversations.',
code: 'LIMIT_REACHED',
data: {
limit: user.conversationLimit,
used: user.conversationCount,
tier: user.subscriptionTier,
upgradeUrl: `/${locale}/subscription`
}
}, { status: 403 })
}
}
// User is within limits - increment counter for new conversations
await prisma.user.update({
where: { id: userId },
data: {
conversationCount: { increment: 1 },
// Set initial reset date if not set
limitResetDate: user.limitResetDate || new Date(now.setMonth(now.getMonth() + 1))
}
})
}
2.2 Create Utility Functions
File: lib/subscription-utils.ts (NEW)
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export const SUBSCRIPTION_LIMITS = {
free: 10,
premium: Infinity
}
export const STRIPE_PRICES = {
premium_monthly: process.env.STRIPE_PREMIUM_MONTHLY_PRICE_ID!,
premium_yearly: process.env.STRIPE_PREMIUM_YEARLY_PRICE_ID!
}
export async function checkConversationLimit(userId: string): Promise<{
allowed: boolean
remaining: number
limit: number
tier: string
}> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
subscriptionTier: true,
conversationCount: true,
conversationLimit: true,
limitResetDate: true
}
})
if (!user) {
throw new Error('User not found')
}
// Reset if needed
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
}
const remaining = user.conversationLimit - user.conversationCount
const allowed = user.subscriptionTier === 'premium' || remaining > 0
return {
allowed,
remaining: user.subscriptionTier === 'premium' ? Infinity : remaining,
limit: user.conversationLimit,
tier: user.subscriptionTier
}
}
export function getTierFromPriceId(priceId: string): string {
if (priceId === STRIPE_PRICES.premium_monthly || priceId === STRIPE_PRICES.premium_yearly) {
return 'premium'
}
return 'free'
}
export function getIntervalFromPriceId(priceId: string): string {
if (priceId === STRIPE_PRICES.premium_yearly) return 'year'
return 'month'
}
Phase 3: Stripe Subscription Integration
3.1 Create Stripe Products & Prices
Manual Step - Stripe Dashboard:
- Go to Stripe Dashboard → Products
- Create product: "Biblical Guide Premium"
- Add prices:
- Monthly: $10/month (ID: save to env as
STRIPE_PREMIUM_MONTHLY_PRICE_ID) - Yearly: $100/year (ID: save to env as
STRIPE_PREMIUM_YEARLY_PRICE_ID)
- Monthly: $10/month (ID: save to env as
3.2 Update Environment Variables
File: .env.local and .env.example
Add:
# Stripe Subscription Price IDs
STRIPE_PREMIUM_MONTHLY_PRICE_ID=price_xxxxxxxxxxxxx
STRIPE_PREMIUM_YEARLY_PRICE_ID=price_xxxxxxxxxxxxx
3.3 Create Subscription Checkout API
File: app/api/subscriptions/checkout/route.ts (NEW)
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { stripe } from '@/lib/stripe'
import prisma from '@/lib/db'
import { verifyToken } from '@/lib/auth'
const checkoutSchema = z.object({
priceId: z.string(),
interval: z.enum(['month', 'year']),
locale: z.string().default('en')
})
export async function POST(request: Request) {
try {
// Verify authentication
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ success: false, error: 'Authentication required' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const payload = await verifyToken(token)
const userId = payload.userId
// Get user
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true, stripeCustomerId: true, subscriptionTier: true }
})
if (!user) {
return NextResponse.json(
{ success: false, error: 'User not found' },
{ status: 404 }
)
}
// Check if already premium
if (user.subscriptionTier === 'premium') {
return NextResponse.json(
{ success: false, error: 'Already subscribed to Premium' },
{ status: 400 }
)
}
const body = await request.json()
const { priceId, interval, locale } = checkoutSchema.parse(body)
// Create or retrieve Stripe customer
let customerId = user.stripeCustomerId
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId }
})
customerId = customer.id
await prisma.user.update({
where: { id: userId },
data: { stripeCustomerId: customerId }
})
}
// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1
}
],
success_url: `${process.env.NEXTAUTH_URL}/${locale}/subscription/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXTAUTH_URL}/${locale}/subscription`,
metadata: {
userId,
interval
},
subscription_data: {
metadata: {
userId
}
}
})
return NextResponse.json({
success: true,
sessionId: session.id,
url: session.url
})
} catch (error) {
console.error('Subscription checkout error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to create checkout session' },
{ status: 500 }
)
}
}
3.4 Create Customer Portal API
File: app/api/subscriptions/portal/route.ts (NEW)
import { NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe'
import prisma from '@/lib/db'
import { verifyToken } from '@/lib/auth'
export async function POST(request: Request) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ success: false, error: 'Authentication required' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const payload = await verifyToken(token)
const userId = payload.userId
const user = await prisma.user.findUnique({
where: { id: userId },
select: { stripeCustomerId: true }
})
if (!user?.stripeCustomerId) {
return NextResponse.json(
{ success: false, error: 'No subscription found' },
{ status: 404 }
)
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXTAUTH_URL}/settings`
})
return NextResponse.json({
success: true,
url: session.url
})
} catch (error) {
console.error('Customer portal error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to create portal session' },
{ status: 500 }
)
}
}
3.5 Update Stripe Webhook Handler
File: app/api/stripe/webhook/route.ts
Add new event handlers after existing donation handlers:
// After existing event handlers, add:
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object
const userId = subscription.metadata.userId
if (!userId) {
console.warn('No userId in subscription metadata')
break
}
const priceId = subscription.items.data[0]?.price.id
const tier = getTierFromPriceId(priceId)
const interval = getIntervalFromPriceId(priceId)
await prisma.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
create: {
userId,
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
stripeCustomerId: subscription.customer as string,
status: subscription.status.toUpperCase(),
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
tier,
interval
},
update: {
status: subscription.status.toUpperCase(),
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end
}
})
// Update user subscription tier
await prisma.user.update({
where: { id: userId },
data: {
subscriptionTier: tier,
conversationLimit: tier === 'premium' ? 999999 : 10,
subscriptionStatus: subscription.status,
stripeSubscriptionId: subscription.id
}
})
console.log(`✅ Subscription ${subscription.status} for user ${userId}`)
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object
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
if (invoice.subscription) {
console.log(`✅ Payment succeeded for subscription ${invoice.subscription}`)
}
break
}
case 'invoice.payment_failed': {
const invoice = event.data.object
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
}
Phase 4: Frontend Implementation
4.1 Subscription Page
File: app/[locale]/subscription/page.tsx (NEW)
Full subscription management page with:
- Current plan display
- Usage stats (conversations used/remaining)
- Upgrade options
- Monthly/yearly toggle
- Stripe checkout integration
- Manage subscription button (portal link)
4.2 Upgrade Modal Component
File: components/subscription/upgrade-modal.tsx (NEW)
Modal shown when limit is reached:
- Clear messaging about limit
- Show current usage
- Upgrade CTA
- Pricing display
4.3 Usage Display Component
File: components/subscription/usage-display.tsx (NEW)
Shows in settings/profile:
- Conversations used this month
- Progress bar
- Reset date
- Current tier badge
4.4 Success Page
File: app/[locale]/subscription/success/page.tsx (NEW)
Thank you page after successful subscription
4.5 Update Settings Page
File: app/[locale]/settings/page.tsx
Add subscription section showing:
- Current plan
- Usage stats
- Manage/upgrade buttons
Phase 5: Translation Keys
5.1 Add to Translation Files
Files: messages/en.json, messages/ro.json, messages/es.json, messages/it.json
Add complete subscription translation keys:
- Plan names and descriptions
- Upgrade prompts
- Usage messages
- Error messages
- Success messages
Phase 6: Testing Checklist
Subscription Flow
- Free user creates 10 conversations successfully
- 11th conversation blocked with upgrade prompt
- Upgrade to Premium via Stripe Checkout
- Webhook updates user to Premium tier
- Premium user has unlimited conversations
- Monthly counter resets correctly
- Cancel subscription (remains premium until period end)
- Subscription expires → downgrade to free
- Payment failure handling
Edge Cases
- User with existing Stripe customer ID
- Multiple subscriptions (should prevent)
- Webhook arrives before user returns from checkout
- Invalid webhook signatures
- Database transaction failures
- Subscription status edge cases (past_due, unpaid, etc.)
Phase 7: Deployment
7.1 Environment Variables
Add to production:
STRIPE_PREMIUM_MONTHLY_PRICE_ID=price_live_xxxxx
STRIPE_PREMIUM_YEARLY_PRICE_ID=price_live_xxxxx
7.2 Stripe Webhook Configuration
Production webhook endpoint:
- URL:
https://biblical-guide.com/api/stripe/webhook - Events:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failedcheckout.session.completed(existing)checkout.session.expired(existing)
7.3 Database Migration
Run in production:
npx prisma migrate deploy
7.4 Monitoring
Set up monitoring for:
- Subscription webhook failures
- Payment failures
- Limit enforcement errors
- Subscription status inconsistencies
Implementation Order
- Phase 1 - Database schema (30 min)
- Phase 2 - Conversation limits (1 hour)
- Phase 3 - Stripe subscription APIs (2 hours)
- Phase 4 - Frontend pages (3 hours)
- Phase 5 - Translations (1 hour)
- Phase 6 - Testing (2 hours)
- Phase 7 - Deployment (1 hour)
Total Estimated Time: 10-12 hours
Success Metrics
Technical
- ✅ All webhook events handled correctly
- ✅ No conversation limit bypasses
- ✅ Proper subscription status sync
- ✅ Clean upgrade/downgrade flows
Business
- Track conversion rate: free → premium
- Monitor churn rate
- Track average subscription lifetime
- Monitor support tickets related to limits
Future Enhancements
- Team/family plans
- Annual discount improvements
- Gift subscriptions
- Free trial period for Premium
- Referral program
- Custom limits for special users
- API access tier
- Lifetime access option
Notes
- Keep donations separate from subscriptions
- Donations do NOT grant subscription perks
- Clear communication about free tier limits
- Grace period for payment failures (3 days)
- Prorated charges when upgrading mid-cycle
- Email notifications for limit approaching
- Email notifications for payment issues