name: saas-billing-patterns description: Load when implementing subscription billing for SaaS applications. Applies when building subscription state machines, proration logic, trials, dunning sequences, or metered billing.
Subscription State Machine
Core States
┌─────────────┐
│ TRIALING │ ──expires──> ACTIVE (if card) or EXPIRED
└─────────────┘
│
│ converts
▼
┌─────────────┐
│ ACTIVE │ ──payment fails──> PAST_DUE ──grace expires──> UNPAID
└─────────────┘ │
│ │ payment succeeds
│ user cancels ▼
▼ ┌─────────────┐
┌─────────────┐ │ ACTIVE │
│ CANCELLING │ ──period ends──> └─────────────┘
└─────────────┘
│
▼
┌─────────────┐
│ CANCELLED │
└─────────────┘
Database Schema
CREATE TYPE subscription_status AS ENUM (
'trialing',
'active',
'past_due',
'unpaid',
'cancelling',
'cancelled',
'paused'
);
CREATE TABLE subscriptions (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
plan_id VARCHAR(50) NOT NULL,
status subscription_status NOT NULL DEFAULT 'trialing',
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN DEFAULT FALSE,
cancelled_at TIMESTAMPTZ,
trial_end TIMESTAMPTZ,
stripe_subscription_id VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW()
);
Trial Implementation
Trial Patterns
| Pattern | Behavior | Use Case |
|---|---|---|
| Credit Card Required | Card collected upfront, charged after trial | Higher intent, lower churn |
| No Card Required | Card collected at conversion | Higher trial starts, lower conversion |
| Freemium + Trial | Free tier + trial of premium | Best of both worlds |
Trial Expiration Logic
async function handleTrialExpiring(subscription: Subscription) {
if (subscription.status !== 'trialing') return;
const daysRemaining = differenceInDays(subscription.trial_end, new Date());
if (daysRemaining === 3) {
await sendEmail('trial_ending_soon', subscription.user_id);
}
if (daysRemaining <= 0) {
if (subscription.has_payment_method) {
await convertToActive(subscription);
} else {
await expireTrial(subscription);
}
}
}
Proration
When to Prorate
| Scenario | Action |
|---|---|
| Upgrade mid-cycle | Charge difference immediately |
| Downgrade mid-cycle | Credit balance, apply at renewal |
| Cancel mid-cycle | No refund (or optional credit) |
Proration Calculation
function calculateProration(
currentPlan: Plan,
newPlan: Plan,
daysRemaining: number,
totalDays: number
): number {
const currentDailyRate = currentPlan.price / totalDays;
const newDailyRate = newPlan.price / totalDays;
const unusedCredit = currentDailyRate * daysRemaining;
const newCharge = newDailyRate * daysRemaining;
return newCharge - unusedCredit; // Positive = charge, negative = credit
}
Stripe Proration Modes
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'create_prorations', // or 'none', 'always_invoice'
});
Dunning (Failed Payment Recovery)
Dunning Sequence
Day 0: Payment fails → Status: PAST_DUE → Email: "Payment failed"
Day 3: Retry 1 → Email: "Please update payment method"
Day 7: Retry 2 → Email: "Service at risk"
Day 14: Retry 3 → Email: "Final warning"
Day 21: Grace ends → Status: UNPAID → Revoke access
Implementation
async function handlePaymentFailed(subscription: Subscription) {
await db.subscriptions.update({
where: { id: subscription.id },
data: {
status: 'past_due',
payment_failed_at: new Date(),
},
});
await sendEmail('payment_failed', subscription.user_id, {
updatePaymentUrl: `/billing/update-payment?sub=${subscription.id}`,
});
// Schedule retries
await queue.add('retry_payment', { subscriptionId: subscription.id }, {
delay: 3 * 24 * 60 * 60 * 1000 // 3 days
});
}
Grace Period Access
function hasAccess(subscription: Subscription): boolean {
if (subscription.status === 'active') return true;
if (subscription.status === 'trialing') return true;
if (subscription.status === 'cancelling') return true;
// Allow grace period access for past_due
if (subscription.status === 'past_due') {
const gracePeriodEnd = addDays(subscription.payment_failed_at, 21);
return new Date() < gracePeriodEnd;
}
return false;
}
Metered/Usage-Based Billing
Usage Tracking
async function recordUsage(
subscriptionId: string,
metric: string,
quantity: number
) {
// For Stripe
await stripe.subscriptionItems.createUsageRecord(
subscriptionItemId,
{
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment', // or 'set'
}
);
// Also store locally for analytics
await db.usageRecords.create({
data: { subscriptionId, metric, quantity, timestamp: new Date() },
});
}
Hybrid Pricing (Base + Usage)
// Plan structure
const plan = {
basePrice: 49, // Monthly base
includedUnits: 1000,
overageRate: 0.01, // Per unit over included
};
function calculateMonthlyBill(basePrice: number, usage: number, included: number, overage: number) {
const overageUnits = Math.max(0, usage - included);
return basePrice + (overageUnits * overage);
}
Feature Gating by Plan
Entitlement Model
const plans = {
free: {
features: ['basic_analytics'],
limits: { api_calls: 100, team_members: 1 },
},
pro: {
features: ['basic_analytics', 'advanced_analytics', 'api_access'],
limits: { api_calls: 10000, team_members: 5 },
},
enterprise: {
features: ['basic_analytics', 'advanced_analytics', 'api_access', 'sso', 'audit_logs'],
limits: { api_calls: -1, team_members: -1 }, // -1 = unlimited
},
};
function hasFeature(user: User, feature: string): boolean {
return plans[user.plan].features.includes(feature);
}
function checkLimit(user: User, limitName: string, currentValue: number): boolean {
const limit = plans[user.plan].limits[limitName];
return limit === -1 || currentValue < limit;
}
Common Gotchas
Timezone Alignment
Bill at midnight in customer's timezone, or pick one consistent timezone (UTC).
Refund Window
Define a clear refund policy (e.g., 14 days) and automate partial refunds.
Plan Changes During Trial
Decide: reset trial, extend trial, or convert immediately?
Annual Pre-Payment
Annual subscriptions should be non-refundable or have clear proration rules.
Tax Calculation
Use Stripe Tax, TaxJar, or similar. Tax rates vary by location and product type.
Quick Reference
| Task | Pattern |
|---|---|
| Trial expiration | Check daily, send emails at 3/1/0 days |
| Upgrade proration | Charge difference immediately |
| Downgrade proration | Credit on next renewal |
| Dunning sequence | Retry at 3, 7, 14 days; revoke at 21 |
| Feature gating | Check plan.features.includes(feature) |
| Usage billing | Record incrementally, bill at period end |