Skip to Content
📚 MyStoryFlow Docs — Your guide to preserving family stories

F021 - Pricing Plans & Billing

Objective

Implement subscription tiers with Stripe integration, supporting free tier, one-time purchases, and monthly subscriptions with proper usage limits and feature gating.

Quick Implementation

Using NextSaaS Components

  • Billing components from NextSaaS
  • Stripe integration patterns
  • Subscription management UI
  • Payment method components
  • Invoice history displays

New Requirements

  • Stripe product/price setup
  • Usage tracking integration
  • Feature flag system
  • Billing portal customization

MVP Implementation

1. Database Schema

-- Subscription plans CREATE TABLE analyzer.subscription_plans ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), stripe_product_id VARCHAR(255) UNIQUE, stripe_price_id VARCHAR(255) UNIQUE, name VARCHAR(100) NOT NULL, code VARCHAR(50) UNIQUE NOT NULL, -- 'free', 'professional', 'bundle', 'enterprise' price_cents INTEGER NOT NULL, billing_period VARCHAR(20), -- 'once', 'monthly', 'yearly' features JSONB NOT NULL, limits JSONB NOT NULL, is_active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT NOW() ); -- User subscriptions CREATE TABLE analyzer.user_subscriptions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES auth.users(id), plan_id UUID REFERENCES analyzer.subscription_plans(id), stripe_subscription_id VARCHAR(255), stripe_customer_id VARCHAR(255), status VARCHAR(50) NOT NULL, -- 'active', 'canceled', 'past_due', 'expired' current_period_start TIMESTAMP, current_period_end TIMESTAMP, cancel_at_period_end BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); -- Usage tracking CREATE TABLE analyzer.usage_records ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES auth.users(id), resource_type VARCHAR(50) NOT NULL, -- 'analysis', 'word_count', 'consultation' resource_id UUID, quantity INTEGER NOT NULL DEFAULT 1, metadata JSONB DEFAULT '{}', recorded_at TIMESTAMP DEFAULT NOW() ); -- One-time purchases CREATE TABLE analyzer.purchases ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES auth.users(id), stripe_payment_intent_id VARCHAR(255) UNIQUE, product_type VARCHAR(50) NOT NULL, -- 'analysis', 'consultation' amount_cents INTEGER NOT NULL, status VARCHAR(50) NOT NULL, -- 'pending', 'completed', 'failed', 'refunded' metadata JSONB DEFAULT '{}', created_at TIMESTAMP DEFAULT NOW() ); -- Seed subscription plans INSERT INTO analyzer.subscription_plans (code, name, price_cents, billing_period, features, limits) VALUES ('free', 'Free Tier', 0, NULL, '{"analyses_per_month": 3, "max_words": 5000, "basic_report": true}', '{"analyses_per_month": 3, "max_words_per_analysis": 5000}' ), ('professional', 'Professional', 3900, 'once', '{"full_analysis": true, "detailed_report": true, "export_pdf": true, "max_words": 150000}', '{"max_words_per_analysis": 150000}' ), ('bundle', 'Author Bundle', 14900, 'monthly', '{"analyses_per_month": 5, "priority_processing": true, "revision_tracking": true, "all_features": true}', '{"analyses_per_month": 5, "max_words_per_analysis": 150000}' ), ('enterprise', 'Enterprise', 39900, 'monthly', '{"unlimited_analyses": true, "api_access": true, "white_label": true, "priority_support": true}', '{"analyses_per_month": -1, "max_words_per_analysis": 200000}' ); -- Indexes CREATE INDEX idx_user_subscriptions_user_id ON analyzer.user_subscriptions(user_id); CREATE INDEX idx_user_subscriptions_status ON analyzer.user_subscriptions(status); CREATE INDEX idx_usage_records_user_id ON analyzer.usage_records(user_id, resource_type);

2. Billing Service

// packages/billing/src/services/billing-service.ts import Stripe from 'stripe' import { createClient } from '@mystoryflow/database/server' export class BillingService { private stripe: Stripe private supabase = getSupabaseBrowserClient() constructor() { this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' }) } async createCheckoutSession( userId: string, planCode: string, successUrl: string, cancelUrl: string ): Promise<string> { // Get plan details const { data: plan } = await this.supabase .from('analyzer.subscription_plans') .select('*') .eq('code', planCode) .single() if (!plan) throw new Error('Plan not found') // Get or create Stripe customer const customer = await this.getOrCreateStripeCustomer(userId) // Create appropriate session based on billing period if (plan.billing_period === 'once') { return this.createOneTimeCheckout(customer.id, plan, successUrl, cancelUrl, userId) } else { return this.createSubscriptionCheckout(customer.id, plan, successUrl, cancelUrl, userId) } } private async createOneTimeCheckout( customerId: string, plan: any, successUrl: string, cancelUrl: string, userId: string ): Promise<string> { const session = await this.stripe.checkout.sessions.create({ customer: customerId, payment_method_types: ['card'], line_items: [ { price_data: { currency: 'usd', product_data: { name: plan.name, description: 'One-time manuscript analysis' }, unit_amount: plan.price_cents }, quantity: 1 } ], mode: 'payment', success_url: successUrl, cancel_url: cancelUrl, metadata: { userId, planCode: plan.code, type: 'one_time' } }) return session.url! } private async createSubscriptionCheckout( customerId: string, plan: any, successUrl: string, cancelUrl: string, userId: string ): Promise<string> { const session = await this.stripe.checkout.sessions.create({ customer: customerId, payment_method_types: ['card'], line_items: [ { price: plan.stripe_price_id, quantity: 1 } ], mode: 'subscription', success_url: successUrl, cancel_url: cancelUrl, subscription_data: { metadata: { userId, planId: plan.id } } }) return session.url! } async handleWebhook(event: Stripe.Event): Promise<void> { switch (event.type) { case 'checkout.session.completed': await this.handleCheckoutComplete(event.data.object as Stripe.Checkout.Session) break case 'customer.subscription.updated': case 'customer.subscription.deleted': await this.handleSubscriptionUpdate(event.data.object as Stripe.Subscription) break case 'invoice.payment_failed': await this.handlePaymentFailed(event.data.object as Stripe.Invoice) break } } private async handleCheckoutComplete(session: Stripe.Checkout.Session) { const userId = session.metadata?.userId if (!userId) return if (session.mode === 'payment') { // Handle one-time purchase await this.supabase .from('analyzer.purchases') .insert({ user_id: userId, stripe_payment_intent_id: session.payment_intent as string, product_type: 'analysis', amount_cents: session.amount_total!, status: 'completed', metadata: { planCode: session.metadata.planCode } }) // Grant one-time analysis credit await this.grantAnalysisCredit(userId) } else { // Handle subscription const subscription = await this.stripe.subscriptions.retrieve( session.subscription as string ) await this.updateUserSubscription(userId, subscription) } } private async updateUserSubscription(userId: string, subscription: Stripe.Subscription) { const planId = subscription.metadata.planId await this.supabase .from('analyzer.user_subscriptions') .upsert({ user_id: userId, plan_id: planId, stripe_subscription_id: subscription.id, stripe_customer_id: subscription.customer as string, status: subscription.status, current_period_start: new Date(subscription.current_period_start * 1000), current_period_end: new Date(subscription.current_period_end * 1000), cancel_at_period_end: subscription.cancel_at_period_end }) } async checkUsageLimits(userId: string, resourceType: string): Promise<boolean> { // Get user's current plan const { data: subscription } = await this.supabase .from('analyzer.user_subscriptions') .select('*, subscription_plans(*)') .eq('user_id', userId) .eq('status', 'active') .single() const plan = subscription?.subscription_plans || await this.getFreePlan() const limits = plan.limits // Check if unlimited if (limits[`${resourceType}s_per_month`] === -1) return true // Count usage in current period const startDate = subscription?.current_period_start || this.getMonthStart() const { count } = await this.supabase .from('analyzer.usage_records') .select('*', { count: 'exact' }) .eq('user_id', userId) .eq('resource_type', resourceType) .gte('recorded_at', startDate) return (count || 0) < limits[`${resourceType}s_per_month`] } async recordUsage( userId: string, resourceType: string, resourceId?: string, quantity: number = 1, metadata?: any ): Promise<void> { await this.supabase .from('analyzer.usage_records') .insert({ user_id: userId, resource_type: resourceType, resource_id: resourceId, quantity, metadata }) } private async getOrCreateStripeCustomer(userId: string): Promise<Stripe.Customer> { // Check if customer exists const { data: user } = await this.supabase .from('analyzer.users') .select('email, stripe_customer_id') .eq('id', userId) .single() if (user?.stripe_customer_id) { return await this.stripe.customers.retrieve(user.stripe_customer_id) as Stripe.Customer } // Create new customer const customer = await this.stripe.customers.create({ email: user?.email, metadata: { userId } }) // Save customer ID await this.supabase .from('analyzer.users') .update({ stripe_customer_id: customer.id }) .eq('id', userId) return customer } private async getFreePlan() { const { data } = await this.supabase .from('analyzer.subscription_plans') .select('*') .eq('code', 'free') .single() return data } private getMonthStart(): Date { const now = new Date() return new Date(now.getFullYear(), now.getMonth(), 1) } }

3. Pricing Page Component

// apps/analyzer-app/src/app/pricing/page.tsx import { Card, Button, Badge } from '@mystoryflow/ui' import { Check, X } from 'lucide-react' import { createClient } from '@mystoryflow/database/server' import { PricingCard } from '@/components/billing/PricingCard' export default async function PricingPage() { const supabase = getSupabaseBrowserClient() // Get subscription plans const { data: plans } = await supabase .from('analyzer.subscription_plans') .select('*') .eq('is_active', true) .order('price_cents') const freePlan = plans?.find(p => p.code === 'free') const professionalPlan = plans?.find(p => p.code === 'professional') const bundlePlan = plans?.find(p => p.code === 'bundle') const enterprisePlan = plans?.find(p => p.code === 'enterprise') return ( <div className="container py-16"> <div className="text-center mb-12"> <h1 className="text-4xl font-bold mb-4"> Choose Your Analysis Plan </h1> <p className="text-xl text-muted-foreground max-w-2xl mx-auto"> Professional manuscript analysis powered by AI. Get detailed feedback in minutes, not weeks. </p> </div> <div className="grid gap-8 lg:grid-cols-4 mb-16"> {/* Free Tier */} <PricingCard plan={freePlan} features={[ '3 analyses per month', 'Up to 5,000 words', 'Basic analysis report', 'Community support' ]} highlighted={false} /> {/* Professional */} <PricingCard plan={professionalPlan} features={[ 'One complete analysis', 'Up to 150,000 words', 'Detailed 20-page report', 'Export to PDF', '48-hour turnaround' ]} highlighted={false} badge="Pay Once" /> {/* Bundle */} <PricingCard plan={bundlePlan} features={[ '5 analyses per month', 'Priority processing', 'Revision tracking', 'Progress dashboard', 'Email support' ]} highlighted={true} badge="Most Popular" /> {/* Enterprise */} <PricingCard plan={enterprisePlan} features={[ 'Unlimited analyses', 'API access', 'White-label option', 'Dedicated support', 'Custom integrations' ]} highlighted={false} badge="For Teams" /> </div> {/* Feature Comparison */} <Card className="p-8"> <h2 className="text-2xl font-bold mb-6">Compare Features</h2> <FeatureComparison plans={[freePlan, professionalPlan, bundlePlan, enterprisePlan]} /> </Card> {/* FAQ Section */} <div className="mt-16 max-w-3xl mx-auto"> <h2 className="text-2xl font-bold mb-8 text-center"> Frequently Asked Questions </h2> <FAQ /> </div> </div> ) } function FeatureComparison({ plans }: { plans: any[] }) { const features = [ { name: 'Manuscript Analysis', free: '✓', professional: '✓', bundle: '✓', enterprise: '✓' }, { name: 'Word Limit', free: '5,000', professional: '150,000', bundle: '150,000', enterprise: '200,000' }, { name: 'Analyses per Month', free: '3', professional: '1', bundle: '5', enterprise: 'Unlimited' }, { name: 'Processing Time', free: '5 min', professional: '5 min', bundle: 'Priority', enterprise: 'Priority' }, { name: 'Detailed Report', free: '✗', professional: '✓', bundle: '✓', enterprise: '✓' }, { name: 'PDF Export', free: '✗', professional: '✓', bundle: '✓', enterprise: '✓' }, { name: 'Revision Tracking', free: '✗', professional: '✗', bundle: '✓', enterprise: '✓' }, { name: 'Progress Dashboard', free: '✗', professional: '✗', bundle: '✓', enterprise: '✓' }, { name: 'API Access', free: '✗', professional: '✗', bundle: '✗', enterprise: '✓' }, { name: 'Support', free: 'Community', professional: 'Email', bundle: 'Priority', enterprise: 'Dedicated' } ] return ( <div className="overflow-x-auto"> <table className="w-full"> <thead> <tr className="border-b"> <th className="text-left py-3 px-4">Feature</th> <th className="text-center py-3 px-4">Free</th> <th className="text-center py-3 px-4">Professional</th> <th className="text-center py-3 px-4">Bundle</th> <th className="text-center py-3 px-4">Enterprise</th> </tr> </thead> <tbody> {features.map((feature, i) => ( <tr key={i} className="border-b"> <td className="py-3 px-4">{feature.name}</td> <td className="text-center py-3 px-4">{feature.free}</td> <td className="text-center py-3 px-4">{feature.professional}</td> <td className="text-center py-3 px-4">{feature.bundle}</td> <td className="text-center py-3 px-4">{feature.enterprise}</td> </tr> ))} </tbody> </table> </div> ) }

4. Billing Management Page

// apps/analyzer-app/src/app/settings/billing/page.tsx 'use client' import { useState } from 'react' import { Card, Button, Badge } from '@mystoryflow/ui' import { CreditCard, FileText, Download } from 'lucide-react' import { useSubscription } from '@/hooks/useSubscription' import { UsageChart } from '@/components/billing/UsageChart' import { InvoiceList } from '@/components/billing/InvoiceList' export default function BillingPage() { const { subscription, usage, loading } = useSubscription() const [managingBilling, setManagingBilling] = useState(false) const handleManageBilling = async () => { setManagingBilling(true) try { const response = await fetch('/api/billing/portal', { method: 'POST' }) const { url } = await response.json() window.location.href = url } catch (error) { console.error('Error:', error) } finally { setManagingBilling(false) } } if (loading) return <div>Loading...</div> return ( <div className="space-y-6"> <h1 className="text-2xl font-bold">Billing & Subscription</h1> {/* Current Plan */} <Card className="p-6"> <div className="flex items-center justify-between mb-4"> <h2 className="text-lg font-semibold">Current Plan</h2> <Badge variant={subscription?.status === 'active' ? 'success' : 'secondary'}> {subscription?.status || 'Free'} </Badge> </div> <div className="space-y-4"> <div> <p className="text-2xl font-bold"> {subscription?.subscription_plans?.name || 'Free Tier'} </p> <p className="text-muted-foreground"> {subscription?.subscription_plans?.price_cents ? `$${(subscription.subscription_plans.price_cents / 100).toFixed(2)}/month` : 'No charge' } </p> </div> {subscription?.current_period_end && ( <p className="text-sm text-muted-foreground"> {subscription.cancel_at_period_end ? `Cancels on ${new Date(subscription.current_period_end).toLocaleDateString()}` : `Renews on ${new Date(subscription.current_period_end).toLocaleDateString()}` } </p> )} <div className="flex gap-3"> <Button onClick={handleManageBilling} disabled={managingBilling || !subscription} > <CreditCard className="h-4 w-4 mr-2" /> Manage Billing </Button> {!subscription && ( <Button variant="outline" asChild> <a href="/pricing">Upgrade Plan</a> </Button> )} </div> </div> </Card> {/* Usage This Month */} <Card className="p-6"> <h2 className="text-lg font-semibold mb-4">Usage This Month</h2> <UsageChart usage={usage} limits={subscription?.subscription_plans?.limits} /> </Card> {/* Invoices */} <Card className="p-6"> <h2 className="text-lg font-semibold mb-4">Invoice History</h2> <InvoiceList customerId={subscription?.stripe_customer_id} /> </Card> </div> ) }

5. Usage Tracking Hook

// apps/analyzer-app/src/hooks/useSubscription.ts import { useEffect, useState } from 'react' import { createClient } from '@mystoryflow/database/client' export function useSubscription() { const [subscription, setSubscription] = useState<any>(null) const [usage, setUsage] = useState<any>({}) const [loading, setLoading] = useState(true) const supabase = getSupabaseBrowserClient() useEffect(() => { loadSubscriptionData() }, []) const loadSubscriptionData = async () => { try { // Get current subscription const { data: sub } = await supabase .from('analyzer.user_subscriptions') .select('*, subscription_plans(*)') .eq('status', 'active') .single() setSubscription(sub) // Get usage for current period const startDate = sub?.current_period_start || getMonthStart() const { data: usageData } = await supabase .from('analyzer.usage_records') .select('resource_type, quantity') .gte('recorded_at', startDate) // Aggregate usage by type const aggregated = usageData?.reduce((acc, record) => { acc[record.resource_type] = (acc[record.resource_type] || 0) + record.quantity return acc }, {}) setUsage(aggregated || {}) } catch (error) { console.error('Error loading subscription:', error) } finally { setLoading(false) } } const checkLimit = (resourceType: string): boolean => { const plan = subscription?.subscription_plans || getFreePlanDefaults() const limit = plan.limits[`${resourceType}s_per_month`] if (limit === -1) return true // Unlimited const used = usage[resourceType] || 0 return used < limit } return { subscription, usage, loading, checkLimit, refresh: loadSubscriptionData } } function getMonthStart(): string { const now = new Date() return new Date(now.getFullYear(), now.getMonth(), 1).toISOString() } function getFreePlanDefaults() { return { limits: { analyses_per_month: 3, max_words_per_analysis: 5000 } } }

MVP Acceptance Criteria

  • Stripe integration for payments
  • Free tier with usage limits
  • One-time purchase option ($39)
  • Monthly subscription tiers
  • Usage tracking and limits
  • Billing portal access
  • Invoice history
  • Plan upgrade/downgrade flow

Post-MVP Enhancements

  • Annual billing discount
  • Team/organization billing
  • Usage-based pricing tiers
  • Referral discounts
  • Promotional codes
  • Gift subscriptions
  • Billing analytics
  • Dunning management

Implementation Time

  • Development: 2.5 days
  • Testing: 0.5 days
  • Total: 3 days

Dependencies

  • Stripe account setup
  • Webhook configuration
  • NextSaaS billing components

Next Feature

After completion, proceed to F022-USAGE-TRACKING for limits.