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
- Current State Analysis
- Tier Structure & Limits
- Trial Tracking Strategy
- Phase 1: Schema & Backend Enforcement
- Phase 2: Stripe Integration
- Phase 3: Frontend UX
- Edge Cases & Handling
- Implementation Timeline
- Testing Strategy
Current State Analysis
Existing Infrastructure
✅ What We Have:
- Convex
subscriptionstable with tier, status, trialEndsAt fields - Convex
usagetable 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 showsfree/personal/pro/team trialEndsAtfield 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:
- Tier enum doesn't match pricing page (free/personal/pro/team)
- No tier limits defined anywhere
- Mutations don't check subscription status before creating resources
Tier Structure & Limits
Pricing Page Tiers (Source of Truth)
| Feature | Free | Personal ($4/mo) | Pro ($9/mo) | Team ($19/mo) |
|---|---|---|---|---|
| Pages | 1 | 3 | Unlimited | Unlimited |
| Tabs/Page | 3 | 5 | Unlimited | Unlimited |
| Widgets | Essential | All Core | All Premium | All Premium |
| Cloud Sync | ❌ Local only | ✅ Real-time | ✅ Real-time | ✅ Real-time |
| Custom Themes | Basic | Basic | ✅ CSS | ✅ CSS |
| AI Credits | - | - | ✅ (coming) | ✅ (coming) |
| API Access | - | - | ✅ (coming) | ✅ |
| Team Features | - | - | - | ✅ Sharing, SSO |
| Support | Community | Community | Priority | Dedicated |
| Trial | N/A | 10 days | 10 days | 10 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:
- Stripe automatically manages trial periods on subscriptions
- Stripe webhooks provide real-time trial status updates
- Stripe handles payment method requirements (optional vs required)
- Centralizes billing logic in one place
- 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
trialEndsAtfrom 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 subscriptionWebhook 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" tier1.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:
-
Create Products:
- Personal Plan ($4/month, $40/year)
- Pro Plan ($9/month, $90/year)
- Team Plan ($19/month, $190/year)
-
Configure Trial Settings:
- Trial Period: 10 days
- Require Payment Method: No
- Trial Behavior: Auto-convert to paid
-
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 const2.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
- Free to Personal upgrade flow
- Trial expiration during active session
- Payment failure + grace period
- Rapid page creation (race conditions)
- 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:
- Disable enforcement checks (allow unlimited)
- Keep tracking/UI in place
- Fix issues in staging
- 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
- Grace period duration: 7 days or 14 days for payment failures?
- Grandfathering: Should existing users get permanent free access?
- Refund policy: Full refund within 30 days or pro-rated?
- 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