User Tier Management Implementation Plan

Status: Planning Phase Priority: Critical - No enforcement currently exists Last Updated: November 2, 2025


Executive Summary

This document outlines the complete implementation strategy for managing user subscription tiers, enforcing limits, and integrating Stripe billing with trial period tracking for homepage.dev.

Current State: System has subscription tables but ZERO enforcement - users can create unlimited resources regardless of tier.

Goal: Implement robust tier-based limits with Stripe integration for billing and trial management.


Table of Contents

  1. Current State Analysis
  2. Tier Structure & Limits
  3. Trial Tracking Strategy
  4. Phase 1: Schema & Backend Enforcement
  5. Phase 2: Stripe Integration
  6. Phase 3: Frontend UX
  7. Edge Cases & Handling
  8. Implementation Timeline
  9. Testing Strategy

Current State Analysis

Existing Infrastructure

✅ What We Have:

  • Convex subscriptions table with tier, status, trialEndsAt fields
  • Convex usage table for tracking metrics
  • Basic CRUD operations in convex/subscriptions.ts
  • Stripe customer IDs stored in subscriptions table

❌ What's Missing:

  • No limit enforcement - users can create unlimited pages/tabs/apps
  • Tier names mismatch: schema uses free/pro/business, pricing page shows free/personal/pro/team
  • trialEndsAt field exists but never populated
  • No Stripe webhook handlers
  • No upgrade/downgrade UI flows
  • No usage indicators or upgrade prompts

Schema Review

Current Schema (convex/schema.ts:483-504):

subscriptions: defineTable({
  userId: v.string(),
  stripeCustomerId: v.string(),
  stripeSubscriptionId: v.optional(v.string()),
  tier: v.union(v.literal("free"), v.literal("pro"), v.literal("business")), // ❌ Mismatch
  status: v.union("active", "canceled", "past_due", "trialing", "incomplete"),
  currentPeriodStart: v.number(),
  currentPeriodEnd: v.number(),
  cancelAtPeriodEnd: v.boolean(),
  trialEndsAt: v.optional(v.number()), // ✅ Exists but unused
})

Issues Identified:

  1. Tier enum doesn't match pricing page (free/personal/pro/team)
  2. No tier limits defined anywhere
  3. Mutations don't check subscription status before creating resources

Tier Structure & Limits

Pricing Page Tiers (Source of Truth)

FeatureFreePersonal ($4/mo)Pro ($9/mo)Team ($19/mo)
Pages13UnlimitedUnlimited
Tabs/Page35UnlimitedUnlimited
WidgetsEssentialAll CoreAll PremiumAll Premium
Cloud Sync❌ Local only✅ Real-time✅ Real-time✅ Real-time
Custom ThemesBasicBasic✅ CSS✅ CSS
AI Credits--✅ (coming)✅ (coming)
API Access--✅ (coming)
Team Features---✅ Sharing, SSO
SupportCommunityCommunityPriorityDedicated
TrialN/A10 days10 days10 days

Implementation Constants

// convex/_helpers/tierLimits.ts (NEW FILE)

export const TIER_LIMITS = {
  free: {
    maxPages: 1,
    maxTabsPerPage: 3,
    maxAppsPerTab: null, // unlimited for now
    maxStorage: 10 * 1024 * 1024, // 10MB
    features: {
      cloudSync: false,
      premiumWidgets: false,
      aiCredits: 0,
      customThemes: false,
      apiAccess: false,
      teamSharing: false,
    }
  },

  personal: {
    maxPages: 3,
    maxTabsPerPage: 5,
    maxAppsPerTab: null,
    maxStorage: 100 * 1024 * 1024, // 100MB
    features: {
      cloudSync: true,
      premiumWidgets: false,
      aiCredits: 0,
      customThemes: false,
      apiAccess: false,
      teamSharing: false,
    }
  },

  pro: {
    maxPages: null, // unlimited
    maxTabsPerPage: null,
    maxAppsPerTab: null,
    maxStorage: 1024 * 1024 * 1024, // 1GB
    features: {
      cloudSync: true,
      premiumWidgets: true,
      aiCredits: 100, // monthly credits (when feature launches)
      customThemes: true,
      apiAccess: true, // coming soon
      teamSharing: false,
    }
  },

  team: {
    maxPages: null,
    maxTabsPerPage: null,
    maxAppsPerTab: null,
    maxStorage: 10 * 1024 * 1024 * 1024, // 10GB
    maxMembers: 10,
    features: {
      cloudSync: true,
      premiumWidgets: true,
      aiCredits: 500,
      customThemes: true,
      apiAccess: true,
      teamSharing: true,
      sso: true,
      analytics: true,
    }
  }
} as const

export type TierName = keyof typeof TIER_LIMITS
export type TierLimits = typeof TIER_LIMITS[TierName]

/**
 * Get limits for a specific tier
 */
export function getTierLimits(tier: TierName): TierLimits {
  return TIER_LIMITS[tier]
}

/**
 * Check if user can perform action based on tier
 */
export function canPerformAction(
  tier: TierName,
  action: 'createPage' | 'createTab' | 'useWidget' | 'enableFeature',
  context?: {
    currentCount?: number
    featureName?: string
    widgetType?: string
  }
): { allowed: boolean; reason?: string; suggestedTier?: TierName } {
  const limits = getTierLimits(tier)

  switch (action) {
    case 'createPage':
      if (limits.maxPages === null) return { allowed: true }
      if ((context?.currentCount ?? 0) >= limits.maxPages) {
        return {
          allowed: false,
          reason: `Page limit reached (${limits.maxPages})`,
          suggestedTier: tier === 'free' ? 'personal' : 'pro'
        }
      }
      return { allowed: true }

    case 'createTab':
      if (limits.maxTabsPerPage === null) return { allowed: true }
      if ((context?.currentCount ?? 0) >= limits.maxTabsPerPage) {
        return {
          allowed: false,
          reason: `Tab limit reached (${limits.maxTabsPerPage} per page)`,
          suggestedTier: tier === 'free' ? 'personal' : 'pro'
        }
      }
      return { allowed: true }

    case 'useWidget':
      // Check if widget is premium and if tier allows it
      const isPremiumWidget = context?.widgetType?.startsWith('premium_')
      if (isPremiumWidget && !limits.features.premiumWidgets) {
        return {
          allowed: false,
          reason: 'Premium widgets require Pro plan',
          suggestedTier: 'pro'
        }
      }
      return { allowed: true }

    case 'enableFeature':
      // Check specific feature flags
      const feature = context?.featureName as keyof typeof limits.features
      if (feature && !limits.features[feature]) {
        return {
          allowed: false,
          reason: `Feature "${feature}" not available on ${tier} plan`,
          suggestedTier: 'pro'
        }
      }
      return { allowed: true }

    default:
      return { allowed: true }
  }
}

Trial Tracking Strategy

Decision: Use Stripe for Trial Management ✅

Rationale:

  1. Stripe automatically manages trial periods on subscriptions
  2. Stripe webhooks provide real-time trial status updates
  3. Stripe handles payment method requirements (optional vs required)
  4. Centralizes billing logic in one place
  5. Handles edge cases (grace periods, failed payments, etc.)

What Stripe Handles:

  • ✅ Trial duration countdown
  • ✅ Automatic conversion to paid after trial
  • ✅ Payment collection at trial end
  • ✅ Trial cancellation handling
  • ✅ Grace periods for payment failures

What We Handle:

  • ✅ Sync trialEndsAt from Stripe webhooks to Convex DB
  • ✅ Display trial countdown in UI
  • ✅ Show upgrade prompts during trial
  • ✅ Enforce limits when trial expires
  • ✅ UI state changes based on trial status

Stripe Configuration:

Product Setup in Stripe Dashboard:

Products:
  - Free (no Stripe subscription needed)
  - Personal - $4/month - 10 day trial
  - Pro - $9/month - 10 day trial
  - Team - $19/month - 10 day trial

Yearly Variants:
  - Personal - $40/year (save 17%)
  - Pro - $90/year (save 17%)
  - Team - $190/year (save 17%)

Trial Settings:
  - Trial Period: 10 days
  - Require Payment Method: No (collect at trial end)
  - Trial Behavior: Auto-convert to paid subscription

Webhook Events to Handle:

// Essential webhooks for trial tracking
const REQUIRED_WEBHOOKS = [
  'customer.subscription.created',      // Trial starts
  'customer.subscription.updated',      // Trial status changes
  'customer.subscription.trial_will_end', // 3 days before trial ends
  'customer.subscription.deleted',      // Cancellation
  'invoice.payment_succeeded',          // Successful payment
  'invoice.payment_failed',             // Failed payment
]

Phase 1: Schema & Backend Enforcement

Priority: 🔴 Critical Timeline: Week 1 Status: Not Started

1.1 Update Schema

File: apps/homepage-web/convex/schema.ts

Changes Required:

// Line 487 - Update tier enum
subscriptions: defineTable({
  userId: v.string(),
  stripeCustomerId: v.string(),
  stripeSubscriptionId: v.optional(v.string()),
  tier: v.union(
    v.literal("free"),
    v.literal("personal"),  // ✅ NEW
    v.literal("pro"),
    v.literal("team")       // ✅ NEW (was "business")
  ),
  status: v.union(
    v.literal("active"),
    v.literal("canceled"),
    v.literal("past_due"),
    v.literal("trialing"),
    v.literal("incomplete")
  ),
  currentPeriodStart: v.number(),
  currentPeriodEnd: v.number(),
  cancelAtPeriodEnd: v.boolean(),
  trialEndsAt: v.optional(v.number()),

  // ✅ NEW: Track when user was notified about trial ending
  trialEndingNotifiedAt: v.optional(v.number()),

  // ✅ NEW: Grace period for expired trials/failed payments
  gracePeriodEnd: v.optional(v.number()),
})

Migration Strategy:

// convex/migrations/updateTierNames.ts
// Existing users with "business" tier → "team"
// All other users → default to "free" tier

1.2 Create Helper Functions

File: apps/homepage-web/convex/_helpers/tierLimits.ts (NEW)

See Tier Structure & Limits section above for complete implementation.

1.3 Enforce Limits in Mutations

Page Creation Enforcement

File: apps/homepage-web/convex/pages.ts

Modify createPage mutation:

import { getTierLimits, canPerformAction } from "./_helpers/tierLimits"

export const createPage = mutation({
  args: {
    homepageId: v.id("homepages"),
    name: v.string(),
    slug: v.string(),
    icon: v.optional(v.string()),
    // ... other args
  },
  handler: async (ctx, args) => {
    // 1. Get authenticated user
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error("Unauthenticated")

    // 2. Get user from Convex
    const user = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
      .first()
    if (!user) throw new Error("User not found")

    // 3. Get user's subscription
    const subscription = await ctx.db
      .query("subscriptions")
      .withIndex("by_user", (q) => q.eq("userId", user.clerkId))
      .first()

    const tier = subscription?.tier || "free"

    // 4. Check current page count
    const currentPages = await ctx.db
      .query("pages")
      .withIndex("by_homepage", (q) => q.eq("homepageId", args.homepageId))
      .collect()

    // 5. Check if user can create page
    const check = canPerformAction(tier, 'createPage', {
      currentCount: currentPages.length
    })

    if (!check.allowed) {
      // Throw error with structured data for frontend to parse
      throw new Error(JSON.stringify({
        code: "LIMIT_REACHED",
        type: "page",
        message: check.reason,
        currentTier: tier,
        suggestedTier: check.suggestedTier,
        currentCount: currentPages.length,
        maxAllowed: getTierLimits(tier).maxPages,
      }))
    }

    // 6. Create page (existing logic)
    const pageId = await ctx.db.insert("pages", {
      homepageId: args.homepageId,
      name: args.name,
      slug: args.slug,
      icon: args.icon,
      order: currentPages.length,
      isDefault: currentPages.length === 0,
      createdBy: user._id,
      lastModifiedBy: user._id,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    })

    return pageId
  },
})

Tab Creation Enforcement

File: apps/homepage-web/convex/tabs.ts

Modify createTab mutation:

export const createTab = mutation({
  args: {
    pageId: v.id("pages"),
    name: v.string(),
    // ... other args
  },
  handler: async (ctx, args) => {
    // 1. Get user and subscription (same as above)
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error("Unauthenticated")

    const user = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
      .first()
    if (!user) throw new Error("User not found")

    const subscription = await ctx.db
      .query("subscriptions")
      .withIndex("by_user", (q) => q.eq("userId", user.clerkId))
      .first()

    const tier = subscription?.tier || "free"

    // 2. Check current tab count for this page
    const currentTabs = await ctx.db
      .query("tabs")
      .withIndex("by_page", (q) => q.eq("pageId", args.pageId))
      .collect()

    // 3. Check if user can create tab
    const check = canPerformAction(tier, 'createTab', {
      currentCount: currentTabs.length
    })

    if (!check.allowed) {
      throw new Error(JSON.stringify({
        code: "LIMIT_REACHED",
        type: "tab",
        message: check.reason,
        currentTier: tier,
        suggestedTier: check.suggestedTier,
        currentCount: currentTabs.length,
        maxAllowed: getTierLimits(tier).maxTabsPerPage,
      }))
    }

    // 4. Create tab (existing logic)
    const tabId = await ctx.db.insert("tabs", {
      // ... tab creation logic
    })

    return tabId
  },
})

Widget/App Creation Enforcement

File: apps/homepage-web/convex/apps.ts

Check premium widget access:

export const createApp = mutation({
  handler: async (ctx, args) => {
    // Get user subscription...
    const tier = subscription?.tier || "free"

    // Get app type to check if premium
    const appType = await ctx.db.get(args.appTypeId)
    if (!appType) throw new Error("App type not found")

    // Check if this is a premium widget
    const check = canPerformAction(tier, 'useWidget', {
      widgetType: appType.type
    })

    if (!check.allowed) {
      throw new Error(JSON.stringify({
        code: "FEATURE_LOCKED",
        type: "premium_widget",
        message: check.reason,
        currentTier: tier,
        suggestedTier: check.suggestedTier,
        widgetName: appType.name,
      }))
    }

    // Create app...
  }
})

1.4 Add Helper Queries

File: apps/homepage-web/convex/subscriptions.ts

Add new queries:

/**
 * Get user's current tier and limits
 */
export const getTierInfo = query({
  args: { userId: v.string() },
  handler: async (ctx, args) => {
    const subscription = await ctx.db
      .query("subscriptions")
      .withIndex("by_user", (q) => q.eq("userId", args.userId))
      .first()

    const tier = subscription?.tier || "free"
    const limits = getTierLimits(tier)

    return {
      tier,
      status: subscription?.status || "active",
      limits,
      isTrialing: subscription?.status === "trialing",
      trialEndsAt: subscription?.trialEndsAt,
      daysLeftInTrial: subscription?.trialEndsAt
        ? Math.ceil((subscription.trialEndsAt - Date.now()) / (1000 * 60 * 60 * 24))
        : null,
    }
  },
})

/**
 * Get user's current usage
 */
export const getCurrentUsage = query({
  args: {
    userId: v.string(),
    homepageId: v.id("homepages")
  },
  handler: async (ctx, args) => {
    // Count pages
    const pages = await ctx.db
      .query("pages")
      .withIndex("by_homepage", (q) => q.eq("homepageId", args.homepageId))
      .collect()

    // Count tabs per page
    const tabsByPage = await Promise.all(
      pages.map(async (page) => {
        const tabs = await ctx.db
          .query("tabs")
          .withIndex("by_page", (q) => q.eq("pageId", page._id))
          .collect()
        return { pageId: page._id, count: tabs.length }
      })
    )

    return {
      pages: pages.length,
      maxTabsOnAnyPage: Math.max(...tabsByPage.map(t => t.count), 0),
      tabsByPage,
    }
  },
})

/**
 * Check if user can perform action (for UI pre-checks)
 */
export const canPerformActionQuery = query({
  args: {
    userId: v.string(),
    action: v.union(
      v.literal("createPage"),
      v.literal("createTab"),
      v.literal("useWidget")
    ),
    context: v.optional(v.any()),
  },
  handler: async (ctx, args) => {
    const subscription = await ctx.db
      .query("subscriptions")
      .withIndex("by_user", (q) => q.eq("userId", args.userId))
      .first()

    const tier = subscription?.tier || "free"

    return canPerformAction(tier, args.action, args.context)
  },
})

Phase 2: Stripe Integration

Priority: 🟡 High Timeline: Week 2 Status: Not Started

2.1 Stripe Product Setup

Manual Steps in Stripe Dashboard:

  1. Create Products:

    • Personal Plan ($4/month, $40/year)
    • Pro Plan ($9/month, $90/year)
    • Team Plan ($19/month, $190/year)
  2. Configure Trial Settings:

    • Trial Period: 10 days
    • Require Payment Method: No
    • Trial Behavior: Auto-convert to paid
  3. Save Price IDs:

// lib/stripe/config.ts
export const STRIPE_PRICE_IDS = {
  personal_monthly: 'price_xxx',
  personal_yearly: 'price_xxx',
  pro_monthly: 'price_xxx',
  pro_yearly: 'price_xxx',
  team_monthly: 'price_xxx',
  team_yearly: 'price_xxx',
} as const

2.2 Webhook Handler

File: apps/homepage-web/app/api/webhooks/stripe/route.ts (NEW)

import { headers } from 'next/headers'
import Stripe from 'stripe'
import { api } from '@/convex/_generated/api'
import { fetchMutation } from 'convex/nextjs'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
})

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(req: Request) {
  const body = await req.text()
  const signature = headers().get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch (err) {
    console.error('Webhook signature verification failed:', err)
    return new Response('Webhook signature verification failed', { status: 400 })
  }

  try {
    switch (event.type) {
      case 'customer.subscription.created':
      case 'customer.subscription.updated': {
        const subscription = event.data.object as Stripe.Subscription
        const userId = subscription.metadata.userId

        if (!userId) {
          console.error('No userId in subscription metadata')
          return new Response('Missing userId', { status: 400 })
        }

        // Map Stripe price ID to our tier
        const tier = getTierFromPriceId(subscription.items.data[0].price.id)

        // Sync to Convex
        await fetchMutation(api.subscriptions.update, {
          userId,
          stripeSubscriptionId: subscription.id,
          tier,
          status: subscription.status as any,
          currentPeriodStart: subscription.current_period_start * 1000,
          currentPeriodEnd: subscription.current_period_end * 1000,
          cancelAtPeriodEnd: subscription.cancel_at_period_end,
          trialEndsAt: subscription.trial_end ? subscription.trial_end * 1000 : undefined,
        })

        console.log(`Subscription ${subscription.id} synced for user ${userId}`)
        break
      }

      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription
        const userId = subscription.metadata.userId

        if (!userId) break

        // Downgrade to free tier
        await fetchMutation(api.subscriptions.update, {
          userId,
          stripeSubscriptionId: undefined,
          tier: 'free',
          status: 'canceled',
        })

        console.log(`User ${userId} downgraded to free tier`)
        break
      }

      case 'customer.subscription.trial_will_end': {
        const subscription = event.data.object as Stripe.Subscription
        const userId = subscription.metadata.userId

        if (!userId) break

        // Mark that we've notified about trial ending (3 days before)
        await fetchMutation(api.subscriptions.update, {
          userId,
          trialEndingNotifiedAt: Date.now(),
        })

        // TODO: Send email notification
        console.log(`Trial ending notification for user ${userId}`)
        break
      }

      case 'invoice.payment_succeeded': {
        const invoice = event.data.object as Stripe.Invoice
        // Payment successful - subscription should already be active
        console.log(`Payment succeeded for invoice ${invoice.id}`)
        break
      }

      case 'invoice.payment_failed': {
        const invoice = event.data.object as Stripe.Invoice
        const subscription = invoice.subscription as string

        // Update status to past_due
        // Stripe will automatically retry payment
        console.log(`Payment failed for invoice ${invoice.id}`)
        break
      }

      default:
        console.log(`Unhandled event type: ${event.type}`)
    }

    return new Response('Webhook processed', { status: 200 })
  } catch (error) {
    console.error('Error processing webhook:', error)
    return new Response('Webhook processing failed', { status: 500 })
  }
}

function getTierFromPriceId(priceId: string): 'free' | 'personal' | 'pro' | 'team' {
  const { STRIPE_PRICE_IDS } = require('@/lib/stripe/config')

  if (priceId === STRIPE_PRICE_IDS.personal_monthly || priceId === STRIPE_PRICE_IDS.personal_yearly) {
    return 'personal'
  }
  if (priceId === STRIPE_PRICE_IDS.pro_monthly || priceId === STRIPE_PRICE_IDS.pro_yearly) {
    return 'pro'
  }
  if (priceId === STRIPE_PRICE_IDS.team_monthly || priceId === STRIPE_PRICE_IDS.team_yearly) {
    return 'team'
  }

  return 'free'
}

2.3 Checkout Flow

File: apps/homepage-web/app/api/checkout/route.ts (NEW)

import { NextRequest } from 'next/server'
import Stripe from 'stripe'
import { auth } from '@clerk/nextjs'
import { fetchQuery } from 'convex/nextjs'
import { api } from '@/convex/_generated/api'
import { STRIPE_PRICE_IDS } from '@/lib/stripe/config'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
})

export async function POST(req: NextRequest) {
  try {
    const { userId } = auth()
    if (!userId) {
      return Response.json({ error: 'Unauthorized' }, { status: 401 })
    }

    const body = await req.json()
    const { tier, billingPeriod } = body as {
      tier: 'personal' | 'pro' | 'team'
      billingPeriod: 'monthly' | 'yearly'
    }

    // Get user's subscription from Convex
    const subscription = await fetchQuery(api.subscriptions.getByUser, { userId })

    if (!subscription) {
      return Response.json({ error: 'Subscription not found' }, { status: 404 })
    }

    // Get price ID
    const priceKey = `${tier}_${billingPeriod}` as keyof typeof STRIPE_PRICE_IDS
    const priceId = STRIPE_PRICE_IDS[priceKey]

    // Create Stripe Checkout session
    const session = await stripe.checkout.sessions.create({
      customer: subscription.stripeCustomerId,
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      mode: 'subscription',
      subscription_data: {
        trial_period_days: 10,
        metadata: {
          userId,
        },
      },
      success_url: `${req.nextUrl.origin}/dashboard?upgraded=true`,
      cancel_url: `${req.nextUrl.origin}/pricing`,
      allow_promotion_codes: true,
    })

    return Response.json({ url: session.url })
  } catch (error) {
    console.error('Checkout error:', error)
    return Response.json(
      { error: 'Failed to create checkout session' },
      { status: 500 }
    )
  }
}

2.4 Customer Portal

File: apps/homepage-web/app/api/billing-portal/route.ts (NEW)

import { NextRequest } from 'next/server'
import Stripe from 'stripe'
import { auth } from '@clerk/nextjs'
import { fetchQuery } from 'convex/nextjs'
import { api } from '@/convex/_generated/api'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
})

export async function POST(req: NextRequest) {
  try {
    const { userId } = auth()
    if (!userId) {
      return Response.json({ error: 'Unauthorized' }, { status: 401 })
    }

    const subscription = await fetchQuery(api.subscriptions.getByUser, { userId })

    if (!subscription) {
      return Response.json({ error: 'Subscription not found' }, { status: 404 })
    }

    // Create portal session
    const session = await stripe.billingPortal.sessions.create({
      customer: subscription.stripeCustomerId,
      return_url: `${req.nextUrl.origin}/settings/billing`,
    })

    return Response.json({ url: session.url })
  } catch (error) {
    console.error('Portal error:', error)
    return Response.json(
      { error: 'Failed to create portal session' },
      { status: 500 }
    )
  }
}

Phase 3: Frontend UX

Priority: 🟢 Medium Timeline: Week 3 Status: Not Started

3.1 Upgrade Modal Component

File: apps/homepage-web/components/billing/UpgradeModal.tsx (NEW)

"use client"

import { useState } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@workspace/ui/primitives/dialog'
import { Button } from '@workspace/ui/primitives/button'
import { Check } from 'lucide-react'

interface UpgradeModalProps {
  isOpen: boolean
  onClose: () => void
  currentTier: 'free' | 'personal' | 'pro' | 'team'
  suggestedTier: 'personal' | 'pro' | 'team'
  reason?: string
  limitType?: 'page' | 'tab' | 'widget' | 'feature'
}

const TIER_DETAILS = {
  personal: {
    price: '$4',
    period: '/month',
    features: [
      'Up to 3 custom pages',
      '5 tabs per page',
      'All core widgets',
      'Real-time cloud sync',
      'Basic themes',
    ]
  },
  pro: {
    price: '$9',
    period: '/month',
    features: [
      'Unlimited pages & tabs',
      'All premium widgets',
      'Custom themes & CSS',
      'Priority support',
      'AI Helper Credits',
      'API access (soon)',
    ]
  },
  team: {
    price: '$19',
    period: '/month',
    features: [
      'Everything in Pro',
      'Up to 10 team members',
      'Team collaboration',
      'SSO integration',
      'Dedicated support',
    ]
  }
}

export function UpgradeModal({
  isOpen,
  onClose,
  currentTier,
  suggestedTier,
  reason,
  limitType
}: UpgradeModalProps) {
  const [isUpgrading, setIsUpgrading] = useState(false)
  const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'yearly'>('monthly')

  const tierDetails = TIER_DETAILS[suggestedTier]

  const handleUpgrade = async () => {
    setIsUpgrading(true)

    try {
      // Create Stripe Checkout session
      const response = await fetch('/api/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          tier: suggestedTier,
          billingPeriod,
        }),
      })

      const { url } = await response.json()

      // Redirect to Stripe Checkout
      window.location.href = url
    } catch (error) {
      console.error('Upgrade error:', error)
      setIsUpgrading(false)
    }
  }

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="max-w-2xl">
        <DialogHeader>
          <DialogTitle className="text-2xl">
            Upgrade to {suggestedTier.charAt(0).toUpperCase() + suggestedTier.slice(1)}
          </DialogTitle>
          <DialogDescription>
            {reason || `You've reached the limit of your ${currentTier} plan.`}
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-6 py-4">
          {/* Billing Period Toggle */}
          <div className="flex justify-center">
            <div className="inline-flex items-center gap-2 bg-gray-100 rounded-lg p-1">
              <button
                onClick={() => setBillingPeriod('monthly')}
                className={`px-4 py-2 rounded-md text-sm font-medium transition-all cursor-pointer ${
                  billingPeriod === 'monthly'
                    ? 'bg-white text-gray-900 shadow-sm'
                    : 'text-gray-600'
                }`}
              >
                Monthly
              </button>
              <button
                onClick={() => setBillingPeriod('yearly')}
                className={`px-4 py-2 rounded-md text-sm font-medium transition-all cursor-pointer relative ${
                  billingPeriod === 'yearly'
                    ? 'bg-white text-gray-900 shadow-sm'
                    : 'text-gray-600'
                }`}
              >
                Yearly
                <span className="absolute -top-2 -right-2 bg-green-500 text-white text-xs px-1.5 py-0.5 rounded-full font-bold">
                  Save 17%
                </span>
              </button>
            </div>
          </div>

          {/* Pricing */}
          <div className="text-center">
            <div className="text-4xl font-bold text-gray-900">
              {billingPeriod === 'yearly'
                ? `$${parseInt(tierDetails.price.slice(1)) * 10}`
                : tierDetails.price
              }
            </div>
            <div className="text-gray-600">
              {billingPeriod === 'yearly' ? '/year' : tierDetails.period}
            </div>
            {billingPeriod === 'yearly' && (
              <div className="text-sm text-gray-500 mt-1">
                ${(parseInt(tierDetails.price.slice(1)) * 10 / 12).toFixed(2)}/month billed annually
              </div>
            )}
            <div className="text-sm text-blue-600 font-medium mt-2">
              ✨ Includes 10-day free trial
            </div>
          </div>

          {/* Features */}
          <div className="border-t border-gray-200 pt-4">
            <h4 className="font-medium text-gray-900 mb-3">What's included:</h4>
            <ul className="space-y-2">
              {tierDetails.features.map((feature, index) => (
                <li key={index} className="flex items-start gap-2">
                  <Check className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
                  <span className="text-sm text-gray-700">{feature}</span>
                </li>
              ))}
            </ul>
          </div>

          {/* CTA Buttons */}
          <div className="flex gap-3 pt-4">
            <Button
              variant="outline"
              className="flex-1 cursor-pointer"
              onClick={onClose}
              disabled={isUpgrading}
            >
              Maybe Later
            </Button>
            <Button
              className="flex-1 bg-blue-600 hover:bg-blue-700 text-white cursor-pointer"
              onClick={handleUpgrade}
              disabled={isUpgrading}
            >
              {isUpgrading ? 'Starting Trial...' : 'Start Free Trial'}
            </Button>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  )
}

3.2 Usage Badge Component

File: apps/homepage-web/components/billing/UsageBadge.tsx (NEW)

"use client"

import { useQuery } from 'convex/react'
import { useUser } from '@clerk/nextjs'
import { api } from '@/convex/_generated/api'
import { Progress } from '@workspace/ui/primitives/progress'

interface UsageBadgeProps {
  homepageId: string
  type: 'pages' | 'tabs'
}

export function UsageBadge({ homepageId, type }: UsageBadgeProps) {
  const { user } = useUser()
  const tierInfo = useQuery(
    api.subscriptions.getTierInfo,
    user ? { userId: user.id } : 'skip'
  )
  const usage = useQuery(
    api.subscriptions.getCurrentUsage,
    user && homepageId ? { userId: user.id, homepageId: homepageId as any } : 'skip'
  )

  if (!tierInfo || !usage) return null

  const current = type === 'pages' ? usage.pages : usage.maxTabsOnAnyPage
  const max = type === 'pages'
    ? tierInfo.limits.maxPages
    : tierInfo.limits.maxTabsPerPage

  const isUnlimited = max === null
  const percentage = isUnlimited ? 0 : (current / max) * 100
  const isNearLimit = percentage >= 80

  return (
    <div className="flex items-center gap-3 text-sm">
      <div className="flex-1 min-w-[100px]">
        <Progress
          value={percentage}
          className={isNearLimit ? 'bg-orange-200' : 'bg-gray-200'}
        />
      </div>
      <span className={`font-medium ${isNearLimit ? 'text-orange-600' : 'text-gray-600'}`}>
        {current} / {isUnlimited ? '∞' : max} {type}
      </span>
    </div>
  )
}

3.3 Trial Banner Component

File: apps/homepage-web/components/billing/TrialBanner.tsx (NEW)

"use client"

import { useQuery } from 'convex/react'
import { useUser } from '@clerk/nextjs'
import { api } from '@/convex/_generated/api'
import { Button } from '@workspace/ui/primitives/button'
import { X } from 'lucide-react'
import { useState } from 'react'

export function TrialBanner() {
  const { user } = useUser()
  const [dismissed, setDismissed] = useState(false)

  const tierInfo = useQuery(
    api.subscriptions.getTierInfo,
    user ? { userId: user.id } : 'skip'
  )

  if (!tierInfo || !tierInfo.isTrialing || dismissed) return null

  const daysLeft = tierInfo.daysLeftInTrial || 0
  const isUrgent = daysLeft <= 3

  return (
    <div
      className={`relative px-4 py-3 ${
        isUrgent
          ? 'bg-orange-50 border-orange-200'
          : 'bg-blue-50 border-blue-200'
      } border-b`}
    >
      <div className="max-w-7xl mx-auto flex items-center justify-between">
        <div className="flex items-center gap-3">
          <span className={`font-medium ${
            isUrgent ? 'text-orange-900' : 'text-blue-900'
          }`}>
            {daysLeft} {daysLeft === 1 ? 'day' : 'days'} left in your trial
          </span>
          <span className={isUrgent ? 'text-orange-700' : 'text-blue-700'}>
            Upgrade now to keep all your features
          </span>
        </div>

        <div className="flex items-center gap-2">
          <Button
            size="sm"
            className={`${
              isUrgent
                ? 'bg-orange-600 hover:bg-orange-700'
                : 'bg-blue-600 hover:bg-blue-700'
            } text-white cursor-pointer`}
            onClick={() => {
              // Redirect to pricing page
              window.location.href = '/pricing'
            }}
          >
            Upgrade Now
          </Button>

          <button
            onClick={() => setDismissed(true)}
            className="p-1 hover:bg-black/5 rounded cursor-pointer"
          >
            <X className="h-4 w-4" />
          </button>
        </div>
      </div>
    </div>
  )
}

3.4 Enforce in UI Components

Example: Add Page Button with Limit Check

"use client"

import { useState } from 'react'
import { useQuery } from 'convex/react'
import { useUser } from '@clerk/nextjs'
import { api } from '@/convex/_generated/api'
import { Button } from '@workspace/ui/primitives/button'
import { UpgradeModal } from '@/components/billing/UpgradeModal'

export function AddPageButton({ homepageId }: { homepageId: string }) {
  const { user } = useUser()
  const [showUpgradeModal, setShowUpgradeModal] = useState(false)

  const tierInfo = useQuery(
    api.subscriptions.getTierInfo,
    user ? { userId: user.id } : 'skip'
  )

  const usage = useQuery(
    api.subscriptions.getCurrentUsage,
    user && homepageId ? { userId: user.id, homepageId: homepageId as any } : 'skip'
  )

  if (!tierInfo || !usage) return null

  const maxPages = tierInfo.limits.maxPages
  const currentPages = usage.pages
  const canAddPage = maxPages === null || currentPages < maxPages
  const isNearLimit = maxPages !== null && currentPages >= maxPages * 0.8

  const handleClick = async () => {
    if (!canAddPage) {
      setShowUpgradeModal(true)
      return
    }

    // Proceed with adding page...
  }

  return (
    <>
      <Button
        onClick={handleClick}
        disabled={!canAddPage}
        className={`cursor-pointer ${
          isNearLimit ? 'border-orange-400' : ''
        }`}
      >
        {canAddPage
          ? `Add Page ${maxPages ? `(${currentPages}/${maxPages})` : ''}`
          : 'Upgrade to Add More'
        }
      </Button>

      <UpgradeModal
        isOpen={showUpgradeModal}
        onClose={() => setShowUpgradeModal(false)}
        currentTier={tierInfo.tier}
        suggestedTier={tierInfo.tier === 'free' ? 'personal' : 'pro'}
        reason={`You've reached your ${maxPages} page limit`}
        limitType="page"
      />
    </>
  )
}

Edge Cases & Handling

1. Downgrade with Excess Resources

Scenario: User has 5 pages on Pro plan, downgrades to Personal (3 page limit)

Solution:

  • Allow read-only access to all 5 pages
  • Block creating new pages until they delete down to 3
  • Show banner: "You have 5 pages but your plan allows 3. Delete 2 pages to create new ones."
// In createPage mutation
if (currentPages.length >= limits.maxPages) {
  const excessPages = currentPages.length - limits.maxPages
  throw new Error(JSON.stringify({
    code: "EXCESS_RESOURCES",
    message: `You have ${currentPages.length} pages but your ${tier} plan allows ${limits.maxPages}. Please delete ${excessPages} page(s) to create new ones.`,
    currentCount: currentPages.length,
    allowedCount: limits.maxPages,
    excessCount: excessPages,
  }))
}

2. Trial Expiration Mid-Session

Scenario: User's trial expires while they're actively working

Solution:

  • Real-time subscription status check via Convex
  • Show immediate modal on trial expiration
  • Save current work automatically
  • Block new actions until upgraded
// useTrialExpirationCheck hook
export function useTrialExpirationCheck() {
  const { user } = useUser()
  const tierInfo = useQuery(api.subscriptions.getTierInfo,
    user ? { userId: user.id } : 'skip'
  )

  useEffect(() => {
    if (!tierInfo) return

    // Check if trial just expired
    const isExpired = tierInfo.trialEndsAt &&
                      tierInfo.trialEndsAt < Date.now() &&
                      tierInfo.tier !== 'free'

    if (isExpired) {
      // Show modal
      // Auto-save work
      // Block new actions
    }
  }, [tierInfo])
}

3. Payment Failure

Scenario: User's payment fails after trial

Solution:

  • Stripe webhook updates status to past_due
  • 7-day grace period before hard limit
  • Show persistent banner to update payment
  • Send email notifications (3 reminders)
// Grace period check in mutations
if (subscription.status === 'past_due') {
  const gracePeriodEnd = subscription.gracePeriodEnd ||
    (subscription.currentPeriodEnd + 7 * 24 * 60 * 60 * 1000)

  if (Date.now() > gracePeriodEnd) {
    throw new Error(JSON.stringify({
      code: "PAYMENT_REQUIRED",
      message: "Please update your payment method to continue",
    }))
  }
}

4. Concurrent Creation Requests

Scenario: User rapidly clicks "Add Page" multiple times, potentially bypassing limit

Solution:

  • Use Convex's built-in transaction support
  • Race condition protection via atomic operations
  • UI debouncing on creation buttons
// Convex handles this automatically via its transaction system
// But we can add UI debouncing
const [isCreating, setIsCreating] = useState(false)

const handleAddPage = async () => {
  if (isCreating) return
  setIsCreating(true)

  try {
    await createPageMutation({ ... })
  } finally {
    setIsCreating(false)
  }
}

5. Team Plan Resource Tracking

Scenario: Team of 5 users, need to track shared resources

Solution:

  • Track resources at organization level, not per-user
  • Aggregate counts across all team members
  • Show team usage dashboard for admins
// Modified usage query for teams
export const getTeamUsage = query({
  args: { orgId: v.id("organizations") },
  handler: async (ctx, args) => {
    // Count all pages created by any team member
    const allPages = await ctx.db
      .query("pages")
      .filter(q =>
        // Get pages where homepage's org matches
        q.eq(q.field("homepage.orgId"), args.orgId)
      )
      .collect()

    return {
      totalPages: allPages.length,
      totalMembers: // count org members
      // ... other metrics
    }
  }
})

Implementation Timeline

Week 1: Schema & Backend Enforcement

  • Day 1-2: Update schema, create helper functions
  • Day 3-4: Add enforcement to page/tab mutations
  • Day 5: Add usage queries, test enforcement
  • Deliverable: Backend prevents limit violations

Week 2: Stripe Integration

  • Day 1-2: Set up Stripe products, configure trials
  • Day 3-4: Implement webhook handlers
  • Day 5: Create checkout/portal routes, test flows
  • Deliverable: Full Stripe billing integration

Week 3: Frontend UX

  • Day 1-2: Build upgrade modal, usage badges
  • Day 3: Implement trial banner
  • Day 4-5: Add UI enforcement, error handling
  • Deliverable: Complete user-facing tier management

Week 4: Testing & Rollout

  • Day 1-2: Integration testing all flows
  • Day 3: Edge case testing
  • Day 4: Staging deployment
  • Day 5: Production rollout
  • Deliverable: Live tier management system

Testing Strategy

Unit Tests

  • getTierLimits() returns correct limits for each tier
  • canPerformAction() correctly evaluates permissions
  • Subscription queries return expected data

Integration Tests

  • Page creation respects limits
  • Tab creation respects limits
  • Premium widget access controlled by tier
  • Stripe webhooks correctly update Convex
  • Checkout flow creates valid subscriptions

End-to-End Tests

  • Free user hits page limit → sees upgrade modal
  • User upgrades → immediately gets new limits
  • User on trial → sees countdown banner
  • Trial expires → graceful limit enforcement
  • Payment fails → grace period + recovery flow
  • User downgrades → excess resources handled

Manual Test Scenarios

  1. Free to Personal upgrade flow
  2. Trial expiration during active session
  3. Payment failure + grace period
  4. Rapid page creation (race conditions)
  5. Team resource sharing and limits

Rollout Plan

Phase 1: Soft Launch (Beta Users Only)

  • Enable for staff accounts first
  • Monitor Convex logs for errors
  • Test all upgrade flows manually
  • Gather feedback on UX

Phase 2: Controlled Rollout (10% of users)

  • Enable for 10% of existing users
  • Monitor conversion rates
  • Track support tickets
  • Iterate on UI/messaging

Phase 3: Full Production

  • Enable for all users
  • Announce new pricing
  • Grandfather existing free users (optional)
  • Monitor metrics daily

Rollback Plan

If critical issues arise:

  1. Disable enforcement checks (allow unlimited)
  2. Keep tracking/UI in place
  3. Fix issues in staging
  4. Re-enable enforcement

Success Metrics

Technical Metrics

  • Zero limit bypass incidents
  • <100ms overhead for limit checks
  • 99.9% webhook processing success rate
  • <0.1% checkout errors

Business Metrics

  • Trial → Paid conversion rate >30%
  • Upgrade from Free → Personal >15%
  • Churn rate <5% monthly
  • Average revenue per user (ARPU) growth

User Experience Metrics

  • <2% support tickets about limits
  • Upgrade modal conversion >20%
  • Trial notification engagement >40%
  • Customer satisfaction score >4.5/5

Future Enhancements

Phase 2 Features

  • Usage analytics dashboard
  • Custom pricing for enterprise
  • Volume discounts for yearly plans
  • Add-on purchases (extra AI credits, storage)
  • Referral program with credit rewards
  • Annual billing reminder/discount offers

Technical Improvements

  • Real-time usage WebSocket updates
  • Predictive limit warnings (80% usage alerts)
  • A/B test different upgrade messaging
  • Optimize Convex query performance
  • Add caching layer for tier checks

Questions & Decisions

Open Questions

  1. Grace period duration: 7 days or 14 days for payment failures?
  2. Grandfathering: Should existing users get permanent free access?
  3. Refund policy: Full refund within 30 days or pro-rated?
  4. Team pricing: Per-seat or flat rate?

Decisions Made

  • ✅ Use Stripe for trial management (not manual)
  • ✅ Tier names: free/personal/pro/team
  • ✅ Trial duration: 10 days
  • ✅ No credit card required for trial
  • ✅ Hard limits enforced at mutation level

Contact & Support

Technical Lead: [Your Name] Project Manager: [PM Name] Engineering Team: @backend-team Slack Channel: #tier-management Documentation: [Link to additional docs]


Last Updated: November 2, 2025 Next Review: December 1, 2025

On this page

User Tier Management Implementation PlanExecutive SummaryTable of ContentsCurrent State AnalysisExisting InfrastructureSchema ReviewTier Structure & LimitsPricing Page Tiers (Source of Truth)Implementation ConstantsTrial Tracking StrategyDecision: Use Stripe for Trial Management ✅What Stripe Handles:What We Handle:Stripe Configuration:Webhook Events to Handle:Phase 1: Schema & Backend Enforcement1.1 Update Schema1.2 Create Helper Functions1.3 Enforce Limits in MutationsPage Creation EnforcementTab Creation EnforcementWidget/App Creation Enforcement1.4 Add Helper QueriesPhase 2: Stripe Integration2.1 Stripe Product Setup2.2 Webhook Handler2.3 Checkout Flow2.4 Customer PortalPhase 3: Frontend UX3.1 Upgrade Modal Component3.2 Usage Badge Component3.3 Trial Banner Component3.4 Enforce in UI ComponentsEdge Cases & Handling1. Downgrade with Excess Resources2. Trial Expiration Mid-Session3. Payment Failure4. Concurrent Creation Requests5. Team Plan Resource TrackingImplementation TimelineWeek 1: Schema & Backend EnforcementWeek 2: Stripe IntegrationWeek 3: Frontend UXWeek 4: Testing & RolloutTesting StrategyUnit TestsIntegration TestsEnd-to-End TestsManual Test ScenariosRollout PlanPhase 1: Soft Launch (Beta Users Only)Phase 2: Controlled Rollout (10% of users)Phase 3: Full ProductionRollback PlanSuccess MetricsTechnical MetricsBusiness MetricsUser Experience MetricsFuture EnhancementsPhase 2 FeaturesTechnical ImprovementsQuestions & DecisionsOpen QuestionsDecisions MadeContact & Support