Why Billing Is the Hardest Part
Billing seems simple until you start building it. Then you discover proration, failed payment retries, tax calculation, invoice generation, plan changes mid-cycle, usage-based add-ons, and the dozen edge cases that make experienced developers lose sleep.
The golden rule: never build billing from scratch. Use Stripe, Paddle, or LemonSqueezy as your billing engine and focus your engineering effort on the integration, not the payment processing.
Choosing Your Payment Provider
Each provider has distinct strengths:
- Stripe: Most flexible. Best for custom billing logic, usage-based pricing, and complex enterprise scenarios. You handle tax calculation (use Stripe Tax) and invoicing. Highest developer effort but maximum control
- Paddle: Merchant of Record — they handle VAT, sales tax, and invoicing globally. Lower development effort, but less flexibility for custom billing scenarios. Ideal for bootstrapped SaaS selling to global customers
- LemonSqueezy: Similar MoR model to Paddle, developer-friendly, good for indie SaaS. Less mature for enterprise billing scenarios
The Subscription Data Model
Your database needs these core entities:
Plans
├─ id, name, stripe_price_id
├─ features (JSON: what's included)
└─ limits (JSON: usage caps)
Subscriptions
├─ id, tenant_id, plan_id
├─ stripe_subscription_id
├─ status (active, past_due, canceled, trialing)
├─ current_period_start, current_period_end
└─ cancel_at_period_end
Usage Records (if usage-based)
├─ id, tenant_id, metric, quantity
└─ recorded_atCritical: Your local database is a cache of Stripe's data, not the source of truth. Always sync from Stripe webhooks, never from API responses alone.
Webhook Architecture
Webhooks are the backbone of SaaS billing. Stripe sends events for every significant change, and your application must handle them reliably:
// Essential webhooks to handle
checkout.session.completed → Provision access
customer.subscription.updated → Sync plan changes
customer.subscription.deleted → Revoke access
invoice.payment_failed → Notify user, grace period
invoice.paid → Update payment statusWebhook processing rules:
- Idempotent handlers: Stripe may send the same event multiple times. Your handlers must produce the same result regardless of how many times they run
- Verify signatures: Always validate webhook signatures. Never trust unverified webhook payloads
- Process asynchronously: Return 200 immediately, process the event in a background job. Stripe will retry if your endpoint times out
- Store raw events: Log every webhook payload. When billing bugs occur (and they will), these logs are invaluable for debugging
Free Trials That Convert
The typical SaaS trial flow:
- No credit card trial (14 days): Lower friction to start. User explores the product freely
- Trial ending notification (day 11): Email with clear value summary and upgrade prompt
- Trial expired: Account enters read-only mode. Data preserved. Clear upgrade path
- Grace period (7 days): User can still upgrade and retain all data. After grace period, data archived
Track trial engagement metrics: features used, sessions per day, team members invited. Users who invite teammates during trial convert at 3-5x the rate of solo users.
Handling Plan Changes
Plan changes mid-billing-cycle are where most billing implementations break:
- Upgrades: Prorate immediately. Charge the difference for the remaining period and switch to the new plan
- Downgrades: Apply at period end. User keeps current plan features until the billing cycle completes, then switches to the lower plan
- Cancellations: Always cancel at period end, never immediately. Users who "cancel" often change their minds before the period ends
Stripe handles proration automatically if configured correctly. Don't build your own proration logic.
Feature Gating
Your application needs a clean way to check whether a tenant has access to specific features:
// Clean feature gating
if (await tenant.hasFeature('advanced-analytics')) {
// Show advanced analytics
} else {
// Show upgrade prompt
}
// Usage limit checking
const usage = await tenant.getUsage('api-calls');
if (usage >= tenant.plan.limits.apiCalls) {
// Return 429 with upgrade prompt
}Store feature flags and limits on the Plan model, not hardcoded in application logic. This makes it trivial to create new plans or adjust existing ones without code changes.
In Part 4, we'll cover the growth infrastructure: analytics, onboarding flows, and retention mechanics that turn trial users into long-term customers.
Topics
Share this article
Enjoyed this article?
Subscribe to get our latest insights on enterprise tech and digital transformation.