Files
biblical-guide.com/SUBSCRIPTION_IMPLEMENTATION_PLAN.md
Andrei c3cd353f2f 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>
2025-10-12 22:14:22 +00:00

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:

  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:

# 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.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:

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