ined in a centralized JSON/YAML configuration. This serves as the single source of truth.
2. Value Metric Abstraction: The system calculates cost based on a ValueMetric (e.g., API calls, storage, seats) rather than hard-coded features. This aligns price with value and simplifies tier management.
3. Grandfathering Engine: The system automatically detects a user's current plan and applies legacy pricing if the configuration changes, preventing churn due to price hikes.
4. Processor Agnosticism: The pricing engine outputs a structured payload consumed by the payment processor integration (Stripe, LemonSqueezy), ensuring logic remains portable.
TypeScript Implementation
The following implementation provides a robust PricingEngine class. It handles plan evaluation, limit checking, and configuration updates.
// pricing.types.ts
export type PlanId = 'starter' | 'pro' | 'enterprise';
export type BillingCycle = 'monthly' | 'yearly';
export interface PlanConfig {
id: PlanId;
name: string;
prices: Record<BillingCycle, number>;
limits: {
[metric: string]: number | 'unlimited';
};
features: string[];
// Priority determines order in UI; lower number = higher priority
priority: number;
}
export interface PricingConfig {
plans: PlanConfig[];
currency: string;
trialDays: number;
// Enables dynamic price experimentation
activeAbTest?: {
planId: PlanId;
variant: 'control' | 'test';
priceMultiplier: number;
};
}
// pricing.engine.ts
export class SoloPricingEngine {
private config: PricingConfig;
private grandfatheredPlans: Map<string, PlanConfig>;
constructor(initialConfig: PricingConfig) {
this.config = initialConfig;
this.grandfatheredPlans = new Map();
}
/**
* Updates pricing configuration dynamically.
* In production, this would be triggered by a webhook or admin action.
*/
updateConfig(newConfig: PricingConfig): void {
// Detect plan removals or major changes to trigger grandfathering
this.config.plans.forEach(oldPlan => {
const newPlan = newConfig.plans.find(p => p.id === oldPlan.id);
if (!newPlan || newPlan.prices.monthly > oldPlan.prices.monthly * 1.1) {
// Price increased by >10% or plan removed; preserve old config for existing users
this.grandfatheredPlans.set(oldPlan.id, oldPlan);
}
});
this.config = newConfig;
}
/**
* Calculates the effective price for a user, accounting for grandfathering and A/B tests.
*/
calculatePrice(planId: PlanId, billingCycle: BillingCycle, userId?: string): number {
// 1. Check for grandfathered plan
if (userId) {
const legacyPlan = this.grandfatheredPlans.get(planId);
if (legacyPlan) {
return legacyPlan.prices[billingCycle];
}
}
// 2. Retrieve current plan config
const plan = this.config.plans.find(p => p.id === planId);
if (!plan) throw new Error(`Plan ${planId} not found`);
// 3. Apply A/B test multiplier if active
if (this.config.activeAbTest?.planId === planId) {
return plan.prices[billingCycle] * this.config.activeAbTest.priceMultiplier;
}
return plan.prices[billingCycle];
}
/**
* Validates if a usage metric exceeds plan limits.
*/
checkLimit(planId: PlanId, metric: string, currentUsage: number): { allowed: boolean; limit: number } {
const plan = this.config.plans.find(p => p.id === planId);
if (!plan) throw new Error(`Plan ${planId} not found`);
const limit = plan.limits[metric];
if (limit === 'unlimited') return { allowed: true, limit: Infinity };
return {
allowed: currentUsage < limit,
limit
};
}
/**
* Returns sorted plans for UI rendering.
*/
getAvailablePlans(): PlanConfig[] {
return [...this.config.plans].sort((a, b) => a.priority - b.priority);
}
}
Integration Pattern
The pricing engine should be instantiated at application startup or within a dependency injection container. The UI layer consumes getAvailablePlans() and calculatePrice(). When a user subscribes, the calculated price and plan ID are passed to the payment processor.
For usage-based billing, the engine's checkLimit method should be called in the critical path of resource consumption (e.g., API middleware). If allowed is false, the system triggers an upgrade flow or blocks the request, ensuring revenue protection.
// Example: Middleware usage
async function usageLimitMiddleware(req, res, next) {
const engine = req.app.get('pricingEngine');
const userPlan = req.user.planId;
const metric = 'api_calls';
const currentUsage = await getUsage(req.user.id);
const { allowed } = engine.checkLimit(userPlan, metric, currentUsage);
if (!allowed) {
return res.status(402).json({
error: 'Plan limit exceeded',
upgradeUrl: '/upgrade'
});
}
next();
}
Pitfall Guide
-
Hardcoding Prices in UI Components:
- Mistake: Embedding price strings directly in React/Vue components.
- Consequence: Changing a price requires a code change, testing, and deployment. This creates friction that prevents rapid iteration.
- Fix: Prices must reside in the configuration and be injected into the UI via props or context.
-
Ignoring Grandfathering Logic:
- Mistake: Updating prices in Stripe without handling existing subscribers.
- Consequence: Existing users are charged the new price automatically, causing churn and support backlash.
- Fix: Implement a grandfathering mechanism that locks legacy users to their original price for the life of their subscription, as shown in the
SoloPricingEngine implementation.
-
The "Free" Trap with High Support Costs:
- Mistake: Offering a free tier that includes email support or complex features.
- Consequence: Free users consume disproportionate support time, destroying the solo dev's margin.
- Fix: Restrict free tiers to self-service only. Use the pricing engine to enforce
supportLevel limits. If a free user opens a ticket, the system should prompt an upgrade.
-
Over-Engineering Usage Metrics:
- Mistake: Implementing granular usage-based pricing (e.g., per-request billing) for a low-volume tool.
- Consequence: High complexity in tracking, billing estimation, and user confusion.
- Fix: For solos, flat-rate or simple bucket-based pricing is superior. Only use usage-based billing if the cost structure is directly variable and the value metric is obvious to the user.
-
Static Pricing in a Dynamic Market:
- Mistake: Setting prices once and never revisiting them.
- Consequence: Revenue leakage as the product matures and value increases.
- Fix: Use the A/B testing capability of the pricing engine to experiment with price points. Run tests for 30 days, analyze conversion impact, and lock in the winning configuration.
-
Tax and VAT Neglect:
- Mistake: Assuming the payment processor handles all tax compliance automatically without configuration.
- Consequence: Legal liability and unexpected tax bills.
- Fix: Integrate tax calculation services (e.g., Stripe Tax, Avalara) into the pricing engine's calculation flow. Ensure the engine returns the tax-inclusive price for display where required.
-
Feature Gating Complexity:
- Mistake: Creating dozens of boolean flags for features across tiers.
- Consequence: "Feature flag soup" that makes the codebase unmaintainable and confuses users.
- Fix: Focus on value metrics. Gate based on usage limits and outcome-based features. Keep the number of distinct features per tier low to reduce cognitive load.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| B2C Tool with Low Usage Variance | Single Flat Tier | Users prefer simplicity; usage variance is low, making usage-based billing overhead unjustified. | Low support cost; High conversion. |
| B2B API with High Usage Variance | Usage-Based with Cap | Aligns price with value; cap protects users from bill shock and reduces support disputes. | Moderate tracking cost; High LTV potential. |
| Early Stage MVP (<100 Users) | Single Tier, High Price | Validates willingness to pay; reduces feature request noise; maximizes revenue per user. | Low complexity; High signal-to-noise ratio. |
| Mature Product with Diverse Segments | Multi-Tier (Max 3) | Captures different willingness-to-pay; necessary when user needs diverge significantly. | Higher support cost; Requires grandfathering. |
| High Churn Risk Segment | Annual Billing Discount | Improves cash flow; reduces churn by locking users in; simplifies revenue forecasting. | Deferred revenue; Lower churn rate. |
Configuration Template
Copy this JSON structure to initialize your pricing engine. Adjust values based on your value metric and market positioning.
{
"currency": "USD",
"trialDays": 14,
"plans": [
{
"id": "solo",
"name": "Solo Developer",
"priority": 1,
"prices": {
"monthly": 29,
"yearly": 290
},
"limits": {
"projects": 5,
"api_calls": 10000,
"support_tickets": 0
},
"features": ["Core Features", "Community Support"]
},
{
"id": "pro",
"name": "Professional",
"priority": 2,
"prices": {
"monthly": 79,
"yearly": 790
},
"limits": {
"projects": 50,
"api_calls": 100000,
"support_tickets": 5
},
"features": ["Core Features", "Priority Support", "Analytics"]
}
],
"activeAbTest": null
}
Quick Start Guide
- Initialize Engine: Create a
pricing.json file using the template above. Instantiate SoloPricingEngine with this configuration in your app's entry point.
- Wire UI: Replace hardcoded price strings in your pricing page with calls to
engine.getAvailablePlans() and engine.calculatePrice().
- Add Limits: Insert
engine.checkLimit() calls in your API routes or service methods where resources are consumed. Return 402 Payment Required if limits are exceeded.
- Connect Payment: On subscription, pass the plan ID and calculated price to your payment processor. Store the
userId and planId mapping in your database for grandfathering lookups.
- Test Iteration: Update a price in
pricing.json, reload the engine (or trigger a config refresh), and verify the UI and calculation methods reflect the change instantly without redeployment.