| name | saas-billing |
| description | Implement SaaS billing logic — pricing plans, usage metering, quota enforcement, free trials, plan upgrades and downgrades, prorations, and dunning. Use when building billing, plan, quota, trial, upgrade, downgrade, or subscription-management features (works alongside saas-payments for the Stripe integration layer). |
| version | 1.0.0 |
| category | payments |
| triggers | ["billing","subscription","plan","pricing","upgrade","downgrade","trial","quota","usage limit","plan limit","subscription management","billing portal"] |
| specialist | @developer |
| stack_aware | true |
| complexity | intermediate |
| estimated_tokens | 3900 |
| dependencies | ["saas-payments","saas-multitenancy"] |
SaaS Billing & Subscription Management
Where this skill ends
This skill owns product-side billing logic: plan definitions, feature gating, quota tracking, trial state machine, downgrade orchestration, dunning. The patterns describe what our product does given subscription state.
This skill does NOT own:
- Stripe API calls (creating customers, mutating subscriptions, verifying webhooks). Use
saas-payments.
- Webhook signature verification or event delivery. Use
saas-payments.
When implementing a flow that crosses both skills: orchestration logic lives here and calls into saas-payments primitives. Webhook handlers live in saas-payments and call into update functions defined here.
Capability
Implement subscription lifecycle management, plan enforcement, usage tracking, and billing operations. Covers trial periods, plan changes, quota enforcement, and subscription status synchronization with payment providers.
Use Cases
- Trial period management with conversion tracking
- Plan upgrades and downgrades with proration
- Usage quota tracking and enforcement
- Subscription status webhooks handling
- Billing history and invoice access
- Failed payment recovery (dunning)
When NOT to use this skill
This skill covers plan definitions, feature gating, quota tracking, trial state, and downgrade orchestration for self-serve SaaS. It is not the right fit for:
- B2B contract billing with negotiated terms (annual contracts, custom per-customer pricing). Use a CPQ (Salesforce CPQ, HubSpot) or an invoicing-with-terms tool. The flat-plan patterns here do not encode contract-line items.
- Wholesale per-unit pricing with negotiated discounts. Closer to ERP territory; not a SaaS-billing pattern.
- Pure usage metering at very high volume (millions of events per minute). Use Stripe's native metering, Orb, or Metronome directly; the in-app counters here will not scale.
- Per-seat enterprise pricing with custom contract terms. The flat-fee plan patterns here do not fit; use an enterprise billing platform.
Patterns
Plan Definition & Enforcement
When to use: Enforce feature access and limits based on subscription tier
Implementation: Define plans with features and limits, check against current subscription.
const PLANS = {
free: {
id: 'free',
name: 'Free',
price: 0,
limits: {
projects: 3,
teamMembers: 1,
storageGb: 1,
apiRequestsPerMonth: 1000
},
features: ['basic_analytics']
},
pro: {
id: 'pro',
name: 'Pro',
stripePriceId: 'price_pro_monthly',
price: 29,
limits: {
projects: 25,
teamMembers: 10,
storageGb: 50,
apiRequestsPerMonth: 50000
},
features: ['basic_analytics', 'advanced_analytics', 'api_access', 'priority_support']
},
enterprise: {
id: 'enterprise',
name: 'Enterprise',
stripePriceId: 'price_enterprise_monthly',
price: 99,
limits: {
projects: -1,
teamMembers: -1,
storageGb: 500,
apiRequestsPerMonth: -1
},
features: ['basic_analytics', 'advanced_analytics', 'api_access', 'priority_support', 'sso', 'audit_logs', 'custom_integrations']
}
} as const;
function hasFeature(orgPlan: string, feature: string): boolean {
const plan = PLANS[orgPlan];
return plan?.features.includes(feature) ?? false;
}
function checkLimit(orgPlan: string, resource: string, current: number): boolean {
const plan = PLANS[orgPlan];
const limit = plan?.limits[resource];
if (limit === -1) return true;
return current < limit;
}
async function requireFeature(feature: string) {
return async (req: Request, next: NextFunction) => {
const org = req.tenant;
if (!hasFeature(org.plan, feature)) {
throw new PaymentRequiredError(
`Upgrade to access ${feature}`,
{ requiredPlan: getMinimumPlanForFeature(feature) }
);
}
return next();
};
}
Trial Period Management
When to use: Offer time-limited full access before requiring payment
Implementation: Track trial start/end, send reminders, handle expiration.
async function startTrial(organizationId: string, trialDays = 14) {
const trialEnd = new Date();
trialEnd.setDate(trialEnd.getDate() + trialDays);
await db.update(organizations)
.set({
plan: 'pro',
trialEndsAt: trialEnd,
trialStartedAt: new Date()
})
.where(eq(organizations.id, organizationId));
await scheduleTrialReminders(organizationId, trialEnd);
}
async function getSubscriptionStatus(org: Organization) {
if (org.stripeSubscriptionId) {
return { status: 'active', plan: org.plan };
}
if (org.trialEndsAt) {
const now = new Date();
if (now < org.trialEndsAt) {
const daysLeft = Math.ceil(
(org.trialEndsAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
return { status: 'trialing', plan: org.plan, daysLeft };
}
return { status: 'trial_expired', plan: 'free' };
}
return { status: 'free', plan: 'free' };
}
async function handleTrialExpired(organizationId: string) {
await db.update(organizations)
.set({ plan: 'free' })
.where(eq(organizations.id, organizationId));
await notifyTrialExpired(organizationId);
}
Usage Tracking & Quotas
When to use: Track and limit resource consumption per plan
Implementation: Increment usage counters, check against limits before operations.
const usage = pgTable('usage', {
id: uuid('id').primaryKey().defaultRandom(),
organizationId: uuid('organization_id').references(() => organizations.id),
resource: text('resource').notNull(),
count: integer('count').default(0),
periodStart: timestamp('period_start').notNull(),
periodEnd: timestamp('period_end').notNull()
});
async function trackUsage(orgId: string, resource: string, amount = 1) {
const period = getCurrentBillingPeriod(orgId);
await db.insert(usage)
.values({
organizationId: orgId,
resource,
count: amount,
periodStart: period.start,
periodEnd: period.end
})
.onConflictDoUpdate({
target: [usage.organizationId, usage.resource, usage.periodStart],
set: { count: sql`${usage.count} + ${amount}` }
});
}
async function checkQuota(orgId: string, resource: string): Promise<boolean> {
const org = await db.query.organizations.findFirst({
where: eq(organizations.id, orgId)
});
const plan = PLANS[org.plan];
const limit = plan.limits[resource];
if (limit === -1) return true;
const currentUsage = await getCurrentUsage(orgId, resource);
return currentUsage < limit;
}
async function enforceQuota(resource: string) {
return async (req: Request, next: NextFunction) => {
const canProceed = await checkQuota(req.tenantId, resource);
if (!canProceed) {
throw new QuotaExceededError(
`${resource} quota exceeded for your plan`,
{ currentPlan: req.tenant.plan, upgradeUrl: '/settings/billing' }
);
}
await trackUsage(req.tenantId, resource);
return next();
};
}
Plan Changes (Upgrade/Downgrade)
When to use: Allow users to switch between subscription tiers
Implementation: Handle proration, immediate vs end-of-period changes.
async function changePlan(
organizationId: string,
newPlanId: string,
options: { immediate?: boolean } = {}
) {
const org = await db.query.organizations.findFirst({
where: eq(organizations.id, organizationId)
});
const newPlan = PLANS[newPlanId];
if (!newPlan.stripePriceId) {
throw new Error('Cannot subscribe to free plan via Stripe');
}
const isUpgrade = newPlan.price > PLANS[org.plan].price;
await updateStripeSubscriptionPrice({
subscriptionId: org.stripeSubscriptionId,
newPriceId: newPlan.stripePriceId,
prorationBehavior: isUpgrade ? 'always_invoice' : 'create_prorations',
billingCycleAnchor: options.immediate ? 'now' : 'unchanged',
});
await db.update(organizations)
.set({ plan: newPlanId })
.where(eq(organizations.id, organizationId));
if (!isUpgrade) {
await enforceDowngradeLimits(organizationId, newPlanId);
}
return { success: true, newPlan: newPlanId };
}
async function enforceDowngradeLimits(orgId: string, newPlanId: string) {
const limits = PLANS[newPlanId].limits;
const projectCount = await db.query.projects.count({
where: eq(projects.organizationId, orgId)
});
if (limits.projects !== -1 && projectCount > limits.projects) {
await notifyLimitExceeded(orgId, 'projects', projectCount, limits.projects);
}
}
Stack Implementations
{{stack.frontend.framework}} + Stripe
Billing Page Component:
export default async function BillingPage() {
const org = await getCurrentOrg();
const status = await getSubscriptionStatus(org);
const usage = await getCurrentUsageStats(org.id);
return (
<div>
<CurrentPlanCard plan={status.plan} status={status.status} />
{status.status === 'trialing' && (
<TrialBanner daysLeft={status.daysLeft} />
)}
<UsageStats usage={usage} limits={PLANS[status.plan].limits} />
<PlanSelector currentPlan={status.plan} />
<BillingPortalButton />
</div>
);
}
Exit Criteria
Before declaring this skill's work complete, run each check below and paste the output. "Looks right" is not sufficient. Items marked gateable can be wired into project/gates/ for automated phase-exit checks.
| # | Check | Verification | Pass condition | Gateable |
|---|
| 1 | Plan limits enforced server-side, not just UI | grep -rEn "enforceQuota|requireFeature|checkLimit" --include="*.ts" --include="*.js" --include="*.py" . | Match in API route handlers, not only React components. | yes |
| 2 | Usage counters increment on each gated action | Read API handler for one gated action; trace path. | trackUsage(...) called inside the handler before returning. | manual |
| 3 | Trial expiration triggers plan downgrade | grep -rEn "trialEndsAt|trial_ends_at|handleTrialExpired" --include="*.ts" --include="*.js" --include="*.py" . | Job/cron entry that runs daily, downgrades expired-trial orgs to free. | yes |
| 4 | Downgrade enforces limits without hard delete | grep -rEn "deleteExcessProjects|hard.*delete.*projects" --include="*.ts" --include="*.js" --include="*.py" . | Zero matches; archival/grace-period pattern only. | yes |
| 5 | Webhook updates subscription state | grep -rEn "subscription_status|subscriptionStatus" --include="*.ts" --include="*.js" --include="*.py" . | grep -i webhook | Webhook handler writes subscription status; matches saas-payments contract. | manual |
| 6 | Failed payment recovery wired | grep -rEn "invoice.payment_failed|past_due|dunning" --include="*.ts" --include="*.js" --include="*.py" . | Match in webhook handler with notification + grace period. | yes |
| 7 | Plan change audit logged | grep -rEn "audit.*log.*plan|plan.*change.*log" --include="*.ts" --include="*.js" --include="*.py" . | Entry in audit table on plan change. | manual |
| 8 | Quota errors communicate the upgrade path | Read one quota-exceeded error response. | Includes currentPlan, upgradeUrl, or equivalent so the client can present the call to action. | manual |
| 9 | Tests cover trial-expired and downgrade-with-limits | npm test -- --testPathPattern=billing or equivalent. | Tests for expired trial → free, and downgrade where org exceeds new limits. | yes |
If any check fails, do not declare done. Fix and re-run.
Anti-Patterns (Excuse / Rebuttal)
Excuse: "Hiding the upgrade button in the UI is enough; users on the free plan won't access pro features."
Rebuttal: They will, via direct API calls, third-party clients, or by editing the JSX in DevTools. UI gating is for guidance, not enforcement. Every plan gate must be enforced in the API layer, where the request actually does something.
{plan === 'pro' && <CreateProjectButton />}
app.post('/projects', enforceQuota('projects'), createProject);
Excuse: "On downgrade we should just delete the projects over the limit; the user knows what they signed up for."
Rebuttal: Hard-deleting customer data without consent is a support nightmare and, in some jurisdictions, a legal one. Archive instead, notify the user, give them a grace period to either upgrade back or pick which projects to keep. Restore is cheap; recovery from a deleted-by-mistake escalation is not.
await deleteExcessProjects(orgId, limits.projects);
await archiveExcessProjects(orgId, limits.projects);
await sendDowngradeNotification(orgId);