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

@@ -0,0 +1,773 @@
# 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:
```prisma
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
```prisma
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
```bash
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:
```typescript
// 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)
```typescript
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:**
1. Go to Stripe Dashboard → Products
2. Create product: "Biblical Guide Premium"
3. 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`)
### 3.2 Update Environment Variables
**File:** `.env.local` and `.env.example`
Add:
```env
# 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)
```typescript
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)
```typescript
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:
```typescript
// 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:
```env
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.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
- `checkout.session.completed` (existing)
- `checkout.session.expired` (existing)
### 7.3 Database Migration
Run in production:
```bash
npx prisma migrate deploy
```
### 7.4 Monitoring
Set up monitoring for:
- Subscription webhook failures
- Payment failures
- Limit enforcement errors
- Subscription status inconsistencies
---
## Implementation Order
1. **Phase 1** - Database schema (30 min)
2. **Phase 2** - Conversation limits (1 hour)
3. **Phase 3** - Stripe subscription APIs (2 hours)
4. **Phase 4** - Frontend pages (3 hours)
5. **Phase 5** - Translations (1 hour)
6. **Phase 6** - Testing (2 hours)
7. **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