sing across distributed components.
Step 1: Define the Subscription State Machine
Subscriptions must follow deterministic state transitions. Arbitrary status updates cause financial inconsistencies.
export type SubscriptionStatus =
| 'pending'
| 'active'
| 'past_due'
| 'canceled'
| 'expired'
| 'trialing';
export type TransitionEvent =
| 'payment_succeeded'
| 'payment_failed'
| 'plan_changed'
| 'trial_ended'
| 'cancellation_requested'
| 'dunning_exhausted';
const VALID_TRANSITIONS: Record<SubscriptionStatus, TransitionEvent[]> = {
pending: ['payment_succeeded', 'payment_failed'],
active: ['plan_changed', 'payment_failed', 'cancellation_requested'],
past_due: ['payment_succeeded', 'dunning_exhausted'],
canceled: [],
expired: [],
trialing: ['trial_ended', 'payment_failed']
};
Step 2: Implement Idempotent Webhook Processing
Payment gateways deliver events asynchronously and may retry. Deduplication via idempotency keys prevents double-charging or duplicate state changes.
import { createHash } from 'crypto';
interface WebhookPayload {
id: string;
type: string;
data: Record<string, unknown>;
timestamp: string;
}
class IdempotentWebhookProcessor {
private processedKeys: Set<string> = new Set();
async process(payload: WebhookPayload): Promise<boolean> {
const idempotencyKey = createHash('sha256')
.update(`${payload.id}:${payload.type}:${payload.timestamp}`)
.digest('hex');
if (this.processedKeys.has(idempotencyKey)) {
return true; // Already processed
}
try {
await this.applyStateTransition(payload);
this.processedKeys.add(idempotencyKey);
return true;
} catch (error) {
// Log error, queue for retry, do not mark as processed
throw new Error(`Webhook processing failed: ${error.message}`);
}
}
private async applyStateTransition(payload: WebhookPayload): Promise<void> {
// Route to specific handlers based on payload.type
// Example: invoice.payment_succeeded, subscription.updated
}
}
Step 3: Build Proration & Billing Cycle Manager
Plan changes require precise proration calculations. Hardcoding logic creates maintenance debt. Externalize pricing and compute deltas.
interface PricingTier {
planId: string;
monthlyAmount: number;
currency: string;
}
interface ProrationResult {
creditAmount: number;
chargeAmount: number;
nextBillingDate: Date;
}
class ProrationCalculator {
calculate(
currentTier: PricingTier,
newTier: PricingTier,
cycleStart: Date,
cycleEnd: Date,
changeDate: Date
): ProrationResult {
const cycleDuration = cycleEnd.getTime() - cycleStart.getTime();
const daysRemaining = (cycleEnd.getTime() - changeDate.getTime()) / (1000 * 60 * 60 * 24);
const dailyRate = currentTier.monthlyAmount / 30;
const creditAmount = Math.round(dailyRate * daysRemaining * 100) / 100;
const chargeAmount = Math.round((newTier.monthlyAmount / 30) * daysRemaining * 100) / 100;
return {
creditAmount,
chargeAmount,
nextBillingDate: cycleEnd
};
}
}
Step 4: Architectural Decisions & Rationale
- Event-Driven over Synchronous: Billing state changes must be decoupled from HTTP request lifecycles. Payment failures, gateway timeouts, and dunning retries require async queues. Synchronous calls block user flows and increase failure surface area.
- Separate Billing Ledger from Customer Profile: Customer identity and billing state have different consistency requirements. Identity needs strong consistency; billing needs eventual consistency with auditability. Splitting them prevents lock contention and enables independent scaling.
- Distributed Locks for Plan Changes: Concurrent upgrade/downgrade requests cause proration drift. Use Redis-based distributed locks keyed by
subscriptionId to serialize state transitions.
- Circuit Breakers for Payment Gateways: Gateway outages must not cascade. Implement retry policies with exponential backoff and fallback to cached payment methods or manual intervention queues.
- Immutable Event Log for Revenue Recognition: Every billing event, proration, and credit must be appended to an immutable ledger. In-place updates violate ASC 606/IFRS 15 requirements and break financial audits.
Pitfall Guide
-
Ignoring Timezone Boundaries for Billing Cycles
Billing cycles anchored to local time cause premature or delayed charges. Always store and compute cycle boundaries in UTC. Convert to user timezone only for display.
-
Race Conditions During Plan Changes
Simultaneous upgrade and downgrade requests bypass proration logic. Implement optimistic concurrency control or distributed locks. Validate version or etag on subscription state before applying transitions.
-
Hardcoding Pricing and Proration Logic
Pricing tiers change. Hardcoded math requires deployment cycles for business adjustments. Externalize pricing to configuration or a dedicated pricing service. Compute proration dynamically based on cycle timestamps.
-
Missing Dunning Management
Failed payments are recoverable. Without structured retry schedules, email notifications, and payment method update flows, involuntary churn spikes. Implement a dunning state machine with configurable retry windows and grace periods.
-
Treating Webhooks as Fire-and-Forget
Webhook delivery is unreliable. Gateways retry, but your system must acknowledge, deduplicate, and process idempotently. Missing this causes double-billing or missed state transitions.
-
Poor Revenue Recognition Tracking
Revenue cannot be recognized at charge time for subscriptions. It must be recognized over the service period. Systems that record revenue immediately fail compliance. Implement deferred revenue accounting with amortization schedules.
-
Overlooking Tax Jurisdiction Rules
Digital products face varying tax rates by customer location. Ignoring tax calculation causes compliance violations and revenue leakage. Integrate a tax API or maintain jurisdiction rules in configuration. Validate tax inclusion at checkout and invoice generation.
Best Practices from Production:
- Log every state transition with timestamp, actor, and reason.
- Use idempotency keys for all payment operations, not just webhooks.
- Separate billing logic from customer-facing APIs. Expose billing state through a dedicated ledger service.
- Implement reconciliation jobs that compare gateway settlements with internal billing records daily.
- Design for downgrade paths. Customers who cannot downgrade smoothly will cancel entirely.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple SaaS with fixed monthly tiers | Event-Driven State Machine | Predictable billing cycles, low metering complexity, strong audit trail | Low infrastructure cost, moderate development time |
| Usage-based digital assets (APIs, storage, compute) | Usage-Based Hybrid with Metered Event Sourcing | Aligns billing with consumption, reduces churn from overbilling, supports granular pricing | Higher metering infrastructure cost, requires event pipeline |
| Enterprise multi-tier with custom contracts | Monolithic CRUD with Synchronous Billing (short-term) | Fast deployment for sales-driven deals, custom invoicing workflows | High compliance overhead, scales poorly, increases revenue leakage risk |
| Marketplace with third-party sellers | Event-Driven State Machine + Payout Ledger | Separates subscription revenue from marketplace payouts, enables seller reconciliation | Moderate complexity, requires payout service integration |
Configuration Template
// subscription.config.ts
export const SubscriptionConfig = {
stateMachine: {
allowedTransitions: {
pending: ['active', 'canceled'],
active: ['past_due', 'canceled', 'expired'],
past_due: ['active', 'canceled'],
canceled: [],
expired: []
}
},
dunning: {
maxRetries: 4,
retryIntervals: [3, 7, 14, 21], // days
gracePeriod: 7, // days before suspension
notificationChannels: ['email', 'in_app']
},
proration: {
enabled: true,
rounding: 'nearest_cent',
creditApplication: 'immediate', // immediate | next_cycle
currency: 'USD'
},
webhooks: {
idempotencyWindow: '24h',
retryPolicy: {
maxAttempts: 3,
backoff: 'exponential',
baseDelay: 1000 // ms
},
endpoints: [
{ url: '/webhooks/stripe', secretEnv: 'STRIPE_WEBHOOK_SECRET' },
{ url: '/webhooks/paddle', secretEnv: 'PADDLE_WEBHOOK_SECRET' }
]
},
revenueRecognition: {
method: 'straight_line',
deferUntil: 'service_period_start',
ledgerTable: 'billing_events'
}
};
Quick Start Guide
- Initialize State Machine & Config: Copy
SubscriptionConfig into your project. Implement the state transition validator using the allowedTransitions map. Reject any status update that violates the matrix.
- Deploy Idempotent Webhook Handler: Set up an Express/Fastify route at
/webhooks/:provider. Verify signatures using provider secrets. Compute SHA-256 idempotency keys from event.id + type + timestamp. Store processed keys in Redis with a 24-hour TTL.
- Wire Payment Gateway Mock: Use Stripe CLI or Paddle Sandbox to forward test events to your local webhook endpoint. Verify state transitions trigger correctly for
invoice.payment_succeeded, customer.subscription.updated, and invoice.payment_failed.
- Add Proration & Dunning Queues: Implement the
ProrationCalculator class. Route plan change events to a BullMQ/Redis queue. Configure dunning retries with exponential backoff. Test downgrade scenarios to ensure credit application matches configuration.
- Run Reconciliation Job: Schedule a daily cron that fetches settled invoices from the gateway and compares totals against your
billing_events ledger. Log discrepancies for manual review. Deploy to staging before production.