Billing and Subscriptions with Stripe in Makerkit Next.js Prisma
Implement Stripe subscription billing for your SaaS application. Configure pricing tiers, enforce feature limits on boards and feedback, build usage dashboards, and create upgrade prompts that drive conversions.
With organizations and role-based access control in place, TeamPulse has a solid multi-tenant foundation. Now it's time to monetize—without billing, you have a project, not a business. This module implements Stripe subscriptions with real feature limits that create natural upgrade paths.
You'll build something more sophisticated than a simple paywall. TeamPulse will enforce contextual limits: board creation limited by plan, feedback items limited per board. Users will see exactly where they stand and what they'll unlock by upgrading.
Time: ~2-3 hours
Technologies used:
- Stripe - Payment processing
- Better Auth Stripe Plugin - Subscription management
What you'll accomplish:
- Configure Stripe for local development
- Define TeamPulse plans with board and feedback limits
- Gate board creation by subscription plan
- Gate feedback submission by per-board limits
- Build a usage dashboard component
- Create upgrade prompt components
Prerequisites: Complete Module 7: Organizations & Teams first.
TeamPulse Pricing Strategy
Before configuring Stripe, let's define TeamPulse's pricing tiers. Effective SaaS pricing isn't arbitrary—each limit should correspond to how customers actually use the product and when they naturally need more.
| Feature | Free | Starter ($9/mo) | Pro ($29/mo) | Enterprise ($99/mo) |
|---|---|---|---|---|
| Boards | 1 | 3 | 10 | Unlimited |
| Feedback/board | 25 | 100 | 500 | Unlimited |
| Team members | 2 | 5 | 15 | Unlimited |
| Public boards | No | Yes | Yes | Yes |
| Export data | No | No | Yes | Yes |
| Custom branding | No | No | No | Yes |
Why these specific limits?
- Free tier lets users experience the core value (feedback collection) but constrains scale. One board with 25 items is enough to evaluate, but not enough to run a real feedback program.
- Starter unlocks the features small teams need: multiple boards for different products/projects, enough feedback volume for active use, and public boards for customer-facing feedback.
- Pro targets growing teams who need capacity. The 10x jump in feedback limits (500 vs 50) justifies the 3x price increase. Export data becomes valuable once you have substantial feedback to analyze.
- Enterprise removes all limits for large organizations. Custom branding appeals to companies that embed TeamPulse in their own products.
This structure creates natural upgrade moments: "I need a second board," "we're hitting feedback limits," "we want to share publicly."
Step 1: Configure Stripe
Create a Stripe Account
- Go to Stripe Dashboard
- Create an account (or sign in)
- Stay in Test Mode (toggle in the dashboard)
Get Your API Keys
- Go to Developers → API Keys
- Copy your Publishable key (starts with
pk_test_) - Copy your Secret key (starts with
sk_test_)
Add to apps/web/.env.local:
# Stripe KeysSTRIPE_SECRET_KEY=sk_test_your_secret_keyNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_keySet Up Webhook (Local Development)
# Install Stripe CLIbrew install stripe/stripe-cli/stripe# Loginstripe login# Forward webhooks to local serverstripe listen --forward-to localhost:3000/api/auth/stripe/webhookCopy the webhook signing secret (starts with whsec_) to .env.local:
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secretKeep stripe listen running while testing billing.
Create Products in Stripe
Go to Products in Stripe Dashboard and create:
1. TeamPulse Starter
- Monthly: $9.00/month
- Yearly: $90.00/year
2. TeamPulse Pro
- Monthly: $29.00/month
- Yearly: $290.00/year
3. TeamPulse Enterprise
- Monthly: $99.00/month
- Yearly: $990.00/year
Copy each Price ID (starts with price_).
Step 2: Define TeamPulse Plan Limits
The billing configuration lives in packages/billing/config/src/config.ts. This file defines your products, plans, and their limits. The BillingConfig type from @kit/billing provides full type safety.
Configure the Billing Plans
File: packages/billing/config/src/config.ts
import { BillingConfig } from '@kit/billing';export const billingConfig: BillingConfig = { // Default limits for users without a subscription (free tier) defaultLimits: { seats: 2, boards: 1, feedbackPerBoard: 25, publicBoards: 0, // 0 = false for boolean features exportData: 0, customBranding: 0, }, products: [ // Starter Plan { id: 'starter', name: 'Starter', description: 'For small teams getting started', currency: 'USD', features: [ 'Up to 3 feedback boards', '100 feedback items per board', '5 team members', 'Public boards', ], plans: [ { name: 'starter-monthly', planId: process.env.STRIPE_PRICE_STARTER_MONTHLY!, displayName: 'Starter', interval: 'month', cost: 9.00, limits: { seats: 5, boards: 3, feedbackPerBoard: 100, publicBoards: 1, exportData: 0, customBranding: 0, }, }, { name: 'starter-yearly', planId: process.env.STRIPE_PRICE_STARTER_YEARLY!, displayName: 'Starter', interval: 'year', cost: 90.00, limits: { seats: 5, boards: 3, feedbackPerBoard: 100, publicBoards: 1, exportData: 0, customBranding: 0, }, }, ], }, // Pro Plan { id: 'pro', name: 'Pro', description: 'For growing teams', currency: 'USD', badge: 'Popular', highlighted: true, features: [ 'Up to 10 feedback boards', '500 feedback items per board', '15 team members', 'Public boards', 'Export data', ], plans: [ { name: 'pro-monthly', planId: process.env.STRIPE_PRICE_PRO_MONTHLY!, displayName: 'Pro', interval: 'month', cost: 29.00, limits: { seats: 15, boards: 10, feedbackPerBoard: 500, publicBoards: 1, exportData: 1, customBranding: 0, }, }, { name: 'pro-yearly', planId: process.env.STRIPE_PRICE_PRO_YEARLY!, displayName: 'Pro', interval: 'year', cost: 290.00, limits: { seats: 15, boards: 10, feedbackPerBoard: 500, publicBoards: 1, exportData: 1, customBranding: 0, }, }, ], }, // Enterprise Plan { id: 'enterprise', name: 'Enterprise', description: 'For large organizations', currency: 'USD', features: [ 'Unlimited feedback boards', 'Unlimited feedback items', 'Unlimited team members', 'Public boards', 'Export data', 'Custom branding', 'Priority support', ], plans: [ { name: 'enterprise-monthly', planId: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY!, displayName: 'Enterprise', interval: 'month', cost: 99.00, limits: { seats: null, // null = unlimited boards: null, feedbackPerBoard: null, publicBoards: 1, exportData: 1, customBranding: 1, }, }, { name: 'enterprise-yearly', planId: process.env.STRIPE_PRICE_ENTERPRISE_YEARLY!, displayName: 'Enterprise', interval: 'year', cost: 990.00, limits: { seats: null, boards: null, feedbackPerBoard: null, publicBoards: 1, exportData: 1, customBranding: 1, }, }, ], }, ],};export default billingConfig;Key points:
- Use
planIdfield with the Stripe price ID directly - Use
defaultLimitsfor free tier (users without subscriptions) nullvalues mean unlimited- For boolean features, use
1(enabled) or0(disabled) since limits arenumber | null
Add Price IDs to Environment
# apps/web/.env.local# Starter PlanSTRIPE_PRICE_STARTER_MONTHLY=price_xxxxxSTRIPE_PRICE_STARTER_YEARLY=price_xxxxx# Pro PlanSTRIPE_PRICE_PRO_MONTHLY=price_xxxxxSTRIPE_PRICE_PRO_YEARLY=price_xxxxx# Enterprise PlanSTRIPE_PRICE_ENTERPRISE_MONTHLY=price_xxxxxSTRIPE_PRICE_ENTERPRISE_YEARLY=price_xxxxxStep 3: Create Plan Limit Utilities
The SaaS Kit provides a unified billing client via getBilling(auth). This client handles subscription lookup, plan limits, and all billing operations. For TeamPulse, we'll create app-specific helpers that wrap the billing client.
Using the Billing Client
The billing client is obtained by calling getBilling(auth):
import { getBilling } from '@kit/billing/api';import { auth } from '@kit/auth';const billing = await getBilling(auth);// Get all limits for a referenceIdconst { limits, hasSubscription } = await billing.getPlanLimits(organizationId);// Check a specific limitconst { allowed, limit, remaining } = await billing.checkPlanLimit({ referenceId: organizationId, limitKey: 'seats', currentUsage: memberCount,});// Access default limits (free tier)const defaultLimits = billing.defaultPlanLimits;TeamPulse-Specific Utilities
For TeamPulse, we create wrapper utilities that combine limit checking with usage queries:
File: apps/web/lib/billing/plan-limits.ts
import 'server-only';import { cache } from 'react';import { getBilling } from '@kit/billing/api';import { auth } from '@kit/auth';import { getActiveOrganizationId } from '@kit/better-auth/context';import { db } from '@kit/database';// Check if organization can create more boardsexport const canCreateBoard = cache(async () => { const organizationId = await getActiveOrganizationId(); if (!organizationId) { return { allowed: false, current: 0, limit: 0, remaining: 0, hasSubscription: false }; } // Count current boards const currentCount = await db.board.count({ where: { organizationId }, }); // Get billing client and check limit const billing = await getBilling(auth); return billing.checkPlanLimit({ referenceId: organizationId, limitKey: 'boards', currentUsage: currentCount, });});// Check if board can accept more feedbackexport const canCreateFeedback = cache(async (boardId: string) => { const organizationId = await getActiveOrganizationId(); if (!organizationId) { return { allowed: false, current: 0, limit: 0, remaining: 0, hasSubscription: false }; } // Count current feedback in board const currentCount = await db.feedbackItem.count({ where: { boardId }, }); const billing = await getBilling(auth); return billing.checkPlanLimit({ referenceId: organizationId, limitKey: 'feedbackPerBoard', currentUsage: currentCount, });});// Check boolean features (using numeric limits: 1 = true, 0 = false)export const canCreatePublicBoard = cache(async (): Promise<boolean> => { const organizationId = await getActiveOrganizationId(); const billing = await getBilling(auth); if (!organizationId) { return (billing.defaultPlanLimits.publicBoards ?? 0) > 0; } const { limits } = await billing.getPlanLimits(organizationId); return (limits.publicBoards ?? 0) > 0;});export const canExportData = cache(async (): Promise<boolean> => { const organizationId = await getActiveOrganizationId(); const billing = await getBilling(auth); if (!organizationId) { return (billing.defaultPlanLimits.exportData ?? 0) > 0; } const { limits } = await billing.getPlanLimits(organizationId); return (limits.exportData ?? 0) > 0;});Key points:
- Use
getBilling(auth)to obtain the billing client checkPlanLimithandles subscription lookup and limit comparisondefaultPlanLimitsprovides free tier limits from the confignullvalues in limits mean unlimited; missing keys are also treated as unlimited
Step 4: Gate Board Creation
With limit utilities defined, integrate them into the board creation flow. The key is checking limits in the server action (where enforcement happens) and passing limit data to the UI (for display and disabling).
Update the Server Action
File: apps/web/lib/boards/boards-server-actions.ts
import { canCreateBoard } from '@lib/billing/plan-limits';export const createBoardAction = authenticatedActionClient .inputSchema(createBoardSchema) .action(async ({ parsedInput: data, ctx }) => { const logger = await getLogger(); const userId = ctx.user.id; const organizationId = await getActiveOrganizationId(); if (!organizationId) { throw new Error('No active organization'); } // Check board limit const { allowed, current, limit, hasSubscription } = await canCreateBoard(); if (!allowed) { if (!hasSubscription) { throw new Error('Upgrade to a paid plan to create more boards.'); } throw new Error( `Board limit reached (${current}/${limit}). ` + 'Upgrade your plan for more boards.' ); } logger.info( { userId, organizationId, name: data.name }, 'Creating board', ); // ... rest of the action });Update the Create Board Dialog
Show the limit in the UI and disable the button when at limit.
File: apps/web/app/[locale]/(internal)/boards/_components/create-board-dialog.tsx
'use client';import { useState } from 'react';import { Plus, AlertCircle } from 'lucide-react';import { Button } from '@kit/ui/button';import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger,} from '@kit/ui/dialog';import { Alert, AlertDescription } from '@kit/ui/alert';import { CreateBoardForm } from './create-board-form';interface CreateBoardDialogProps { boardLimit: { allowed: boolean; current: number; limit: number | null; remaining: number | null; };}export function CreateBoardDialog({ boardLimit }: CreateBoardDialogProps) { const [open, setOpen] = useState(false); const isAtLimit = !boardLimit.allowed; const limitText = boardLimit.limit ? `${boardLimit.current}/${boardLimit.limit} boards used` : `${boardLimit.current} boards`; return ( <div className="flex items-center gap-4"> <span className="text-sm text-muted-foreground">{limitText}</span> <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> <Button disabled={isAtLimit} data-testid="create-board-button"> <Plus className="mr-2 h-4 w-4" /> Create Board </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Create Feedback Board</DialogTitle> <DialogDescription> Create a new board to collect feedback from your team. </DialogDescription> </DialogHeader> <CreateBoardForm onSuccess={() => setOpen(false)} /> </DialogContent> </Dialog> {isAtLimit && ( <UpgradePrompt feature="boards" /> )} </div> );}Pass Limit Data from Page
File: apps/web/app/[locale]/(internal)/boards/page.tsx
import { canCreateBoard } from '@lib/billing/plan-limits';export default async function BoardsPage() { const boards = await loadBoards(); const boardLimit = await canCreateBoard(); return ( <PageBody> <PageHeader> <div className="flex items-center justify-between"> <div> <h1 className="text-2xl font-bold">Feedback Boards</h1> <p className="text-muted-foreground"> Create boards to collect feedback from your team </p> </div> <CreateBoardDialog boardLimit={boardLimit} /> </div> </PageHeader> {/* ... rest of the page */} </PageBody> );}Step 5: Gate Feedback Submission
Apply the same gating pattern to feedback creation. The difference here is that limits are per-board, not per-organization. This creates more granular upgrade pressure—users might hit feedback limits on one board while others have room.
Update the Server Action
File: apps/web/lib/feedback/feedback-server-actions.ts
import { canCreateFeedback } from '@lib/billing/plan-limits';export const createFeedbackAction = authenticatedActionClient .inputSchema(createFeedbackSchema) .action(async ({ parsedInput: data, ctx }) => { const logger = await getLogger(); const userId = ctx.user.id; const organizationId = await getActiveOrganizationId(); if (!organizationId) { throw new Error('No active organization'); } // Check feedback limit for this board const { allowed, current, limit, hasSubscription } = await canCreateFeedback(data.boardId); if (!allowed) { if (!hasSubscription) { throw new Error('Upgrade to a paid plan to add more feedback.'); } throw new Error( `Feedback limit reached for this board (${current}/${limit}). ` + 'Upgrade your plan for more capacity.' ); } // ... rest of the action });Show Limit in Feedback Dialog
File: apps/web/app/[locale]/(internal)/boards/[boardId]/_components/create-feedback-dialog.tsx
interface CreateFeedbackDialogProps { boardId: string; feedbackLimit: { allowed: boolean; current: number; limit: number | null; remaining: number | null; };}export function CreateFeedbackDialog({ boardId, feedbackLimit,}: CreateFeedbackDialogProps) { const [open, setOpen] = useState(false); const isAtLimit = !feedbackLimit.allowed; const limitText = feedbackLimit.limit ? `${feedbackLimit.current}/${feedbackLimit.limit} items` : `${feedbackLimit.current} items`; return ( <div className="flex items-center gap-4"> <span className="text-sm text-muted-foreground">{limitText}</span> <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger data-testid="create-feedback-button" render={children as React.ReactElement} /> <DialogContent> {isAtLimit ? ( <> <DialogHeader> <DialogTitle>Feedback Limit Reached</DialogTitle> <DialogDescription> This board has reached its feedback limit. </DialogDescription> </DialogHeader> <UpgradePrompt feature="feedback" /> </> ) : ( <> <DialogHeader> <DialogTitle>Submit Feedback</DialogTitle> <DialogDescription> Share a bug report, feature request, or idea. </DialogDescription> </DialogHeader> <CreateFeedbackForm boardId={boardId} onSuccess={() => setOpen(false)} /> </> )} </DialogContent> </Dialog> </div> );}Step 6: Create the Usage Dashboard
Users need visibility into their usage. A dashboard that shows current consumption against limits helps users understand their plan's value and anticipate when they'll need to upgrade. This transparency builds trust and reduces support requests about "why can't I create more boards?"
File: apps/web/app/[locale]/(internal)/settings/billing/_components/usage-dashboard.tsx
import { Progress } from '@kit/ui/progress';import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';import { getBilling } from '@kit/billing/api';import { auth } from '@kit/auth';import { getActiveOrganizationId } from '@kit/better-auth/context';import { canCreateBoard } from '@lib/billing/plan-limits';import { loadBoards } from '@lib/boards/boards-page.loader';export async function UsageDashboard() { const organizationId = await getActiveOrganizationId(); const billing = await getBilling(auth); // Get plan limits (falls back to defaultLimits if no subscription) const { limits, hasSubscription } = organizationId ? await billing.getPlanLimits(organizationId) : { limits: billing.defaultPlanLimits, hasSubscription: false }; const boardLimit = await canCreateBoard(); const boards = await loadBoards(); // Calculate total feedback across all boards const totalFeedback = boards.reduce( (sum, board) => sum + (board.feedbackCount ?? 0), 0, ); return ( <Card> <CardHeader> <CardTitle>Usage</CardTitle> {!hasSubscription && ( <p className="text-sm text-muted-foreground">Free tier</p> )} </CardHeader> <CardContent className="space-y-6"> {/* Boards Usage */} <UsageItem label="Feedback Boards" current={boardLimit.current} limit={boardLimit.limit} /> {/* Per-board breakdown */} <div className="space-y-3"> <h4 className="text-sm font-medium">Feedback per Board</h4> {boards.map((board) => ( <UsageItem key={board.id} label={board.name} current={board.feedbackCount ?? 0} limit={limits.feedbackPerBoard ?? null} size="sm" /> ))} </div> {/* Features */} <div className="space-y-2"> <h4 className="text-sm font-medium">Features</h4> <div className="grid gap-2 text-sm"> <FeatureRow label="Public boards" enabled={limits.publicBoards ?? false} /> <FeatureRow label="Export data" enabled={limits.exportData ?? false} /> <FeatureRow label="Custom branding" enabled={limits.customBranding ?? false} /> </div> </div> </CardContent> </Card> );}interface UsageItemProps { label: string; current: number; limit: number | null; size?: 'default' | 'sm';}function UsageItem({ label, current, limit, size = 'default' }: UsageItemProps) { const percentage = limit ? Math.min(100, (current / limit) * 100) : 0; const isNearLimit = limit && current >= limit * 0.8; const isAtLimit = limit && current >= limit; return ( <div className={size === 'sm' ? 'space-y-1' : 'space-y-2'}> <div className="flex justify-between text-sm"> <span className={size === 'sm' ? 'text-muted-foreground' : ''}> {label} </span> <span className={isAtLimit ? 'text-destructive font-medium' : ''}> {current}{limit ? `/${limit}` : ''} {limit === null && '(unlimited)'} </span> </div> {limit && ( <Progress value={percentage} className={isNearLimit ? 'bg-yellow-100' : ''} /> )} </div> );}interface FeatureRowProps { label: string; enabled: boolean;}function FeatureRow({ label, enabled }: FeatureRowProps) { return ( <div className="flex justify-between"> <span>{label}</span> <span className={enabled ? 'text-green-600' : 'text-muted-foreground'}> {enabled ? 'Included' : 'Upgrade required'} </span> </div> );}Step 7: Create Upgrade Prompts
When users hit a limit, the error message shouldn't be a dead end. Upgrade prompts convert friction into opportunity by explaining what's blocked and offering a clear path forward. These components appear contextually throughout the application wherever limits apply.
File: apps/web/components/upgrade-prompt.tsx
'use client';import Link from 'next/link';import { Sparkles } from 'lucide-react';import { Button } from '@kit/ui/button';import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';const upgradeMessages = { boards: { title: 'Need more boards?', description: 'Upgrade your plan to create additional feedback boards.', }, feedback: { title: 'Feedback limit reached', description: 'This board has reached its feedback limit. Upgrade for more capacity.', }, publicBoards: { title: 'Public boards require a paid plan', description: 'Share boards publicly to collect feedback from anyone.', }, export: { title: 'Export requires Pro or higher', description: 'Export your feedback data for analysis and reporting.', },};interface UpgradePromptProps { feature: keyof typeof upgradeMessages; variant?: 'card' | 'inline';}export function UpgradePrompt({ feature, variant = 'inline',}: UpgradePromptProps) { const message = upgradeMessages[feature]; if (variant === 'card') { return ( <Card className="border-primary/20 bg-primary/5"> <CardHeader className="pb-2"> <CardTitle className="text-base flex items-center gap-2"> <Sparkles className="h-4 w-4 text-primary" /> {message.title} </CardTitle> </CardHeader> <CardContent className="space-y-4"> <p className="text-sm text-muted-foreground"> {message.description} </p> <Button asChild> <Link href="/settings/billing">View Plans</Link> </Button> </CardContent> </Card> ); } return ( <div className="flex items-center gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20"> <Sparkles className="h-4 w-4 text-primary flex-shrink-0" /> <div className="flex-1 text-sm"> <span className="font-medium">{message.title}</span> <span className="text-muted-foreground ml-1">{message.description}</span> </div> <Button size="sm" asChild> <Link href="/settings/billing">Upgrade</Link> </Button> </div> );}Step 8: Test the Complete Flow
Billing code is notoriously easy to get wrong. Test every scenario before deploying—bugs here either lose revenue or frustrate customers.
Test Free Tier Limits
Start with a fresh organization to experience the free tier constraints:
- Create a new organization (starts with free tier)
- Create 1 board (should succeed)
- Try to create a 2nd board (should fail with upgrade prompt)
- Add 25 feedback items to the board (should succeed)
- Try to add a 26th item (should fail with upgrade prompt)
Test Upgrade Flow
Verify the upgrade flow unlocks additional capacity immediately:
- Go to
/settings/billing - Subscribe to Starter plan (test card:
4242 4242 4242 4242) - Verify subscription is active
- Create more boards (up to 3)
- Verify feedback limit increased to 100
Test Downgrade Behavior
When users cancel, they keep their data but lose the ability to create more:
- Cancel subscription
- Existing data should remain accessible
- Creating new boards/feedback should be blocked at free tier limits
Common issues to watch for: Webhook events arriving out of order, subscription status caching showing stale limits, and race conditions when checking limits during concurrent requests.
Checkpoint: Verify It Works
- [ ] Stripe configured with test keys
- [ ] All price IDs in environment
- [ ] Board limit enforced in server action
- [ ] Feedback limit enforced per board
- [ ] Usage dashboard shows current usage
- [ ] Upgrade prompts appear at limits
- [ ] Checkout flow works
- [ ] Subscription status reflected in limits
Run quality checks:
pnpm healthcheckAdvanced: Lifecycle Hooks
Subscription events are opportunities for engagement. When someone subscribes, send a welcome email. When they cancel, ask for feedback (and maybe offer a discount). When their trial is ending, remind them of the value they'll lose.
These hooks let you customize behavior at key moments in the subscription lifecycle:
File: packages/billing/hooks/src/index.ts
export async function onSubscriptionCreated(event: SubscriptionEvent) { // Welcome new subscriber - set expectations and provide onboarding await sendEmail({ to: event.customer.email, template: 'welcome-subscriber', data: { planName: event.plan.name, }, });}export async function onSubscriptionCanceled(event: SubscriptionEvent) { // Learn why they left - this feedback improves your product await sendEmail({ to: event.customer.email, template: 'cancellation-feedback', });}export async function onTrialEnding(event: SubscriptionEvent) { // Create urgency before they lose access await sendEmail({ to: event.customer.email, template: 'trial-ending', data: { daysRemaining: 3, }, });}Beyond emails, hooks can trigger internal workflows: notifying sales when an Enterprise trial starts, logging events for analytics, or provisioning additional resources for high-tier customers.
Module 8 Complete!
You now have:
- [x] TeamPulse-specific plan limits configured
- [x] Board creation gated by subscription
- [x] Feedback submission gated per board
- [x] Usage dashboard showing current limits
- [x] Upgrade prompts when hitting limits
- [x] Complete checkout and subscription flow
Billing transforms TeamPulse from a free tool into a business. The patterns you've implemented—centralized limit definitions, server-side enforcement, client-side display—scale to any subscription-based SaaS. As your product evolves, you can adjust pricing tiers and limits in one place.
Next: In Module 9: Emails, you'll customize email templates and add TeamPulse-specific notifications for feedback activity.