a a CMS or admin panel without touching code.
4. Proration Abstraction: Proration logic is isolated in a calculator module that supports multiple strategies (e.g., immediate, end-of-cycle, zero-revenue) to accommodate different business models.
Step-by-Step Implementation
1. Define the Pricing Schema
Create a type-safe schema that captures the complexity of modern tiers, including base fees, usage blocks, and feature flags.
// src/schema/pricing-tier.schema.ts
import { z } from 'zod';
export const PricingTierSchema = z.object({
id: z.string().uuid(),
name: z.string(),
status: z.enum(['draft', 'active', 'archived']),
currency: z.string().default('usd'),
billingInterval: z.enum(['monthly', 'annual']),
// Base cost
baseFee: z.number().min(0),
// Usage-based components
usageComponents: z.array(z.object({
metric: z.string(), // e.g., 'api_calls', 'storage_gb'
tiers: z.array(z.object({
upTo: z.number().nullable(), // null means unlimited
unitPrice: z.number().min(0),
flatFee: z.number().default(0)
})).nonempty()
})).optional(),
// Feature entitlements
features: z.record(z.union([
z.boolean(),
z.object({
enabled: z.boolean(),
limit: z.number().optional()
})
])),
// Proration strategy
prorationStrategy: z.enum(['immediate', 'end_of_cycle', 'zero_revenue']),
// Metadata for UI/Marketing
metadata: z.record(z.any()).optional()
});
export type PricingTier = z.infer<typeof PricingTierSchema>;
2. Implement the Pricing Engine
The engine evaluates the cost and access rights based on the tier definition and current usage state.
// src/engine/pricing-engine.ts
import { PricingTier } from '../schema/pricing-tier.schema';
interface UsageSnapshot {
metric: string;
currentCount: number;
}
export class PricingEngine {
constructor(private tier: PricingTier) {}
/**
* Calculates the total cost for the current billing cycle
* based on base fees and stepped usage pricing.
*/
calculateCost(usage: UsageSnapshot[]): number {
let total = this.tier.baseFee;
if (!this.tier.usageComponents) return total;
for (const component of this.tier.usageComponents) {
const snapshot = usage.find(u => u.metric === component.metric);
if (!snapshot) continue;
const cost = this.calculateSteppedCost(
snapshot.currentCount,
component.tiers
);
total += cost;
}
return total;
}
/**
* Checks if a feature is accessible.
* Returns boolean or remaining limit for gated features.
*/
checkAccess(featureKey: string): { allowed: boolean; limit?: number; remaining?: number } {
const feature = this.tier.features[featureKey];
if (!feature) return { allowed: false };
if (typeof feature === 'boolean') {
return { allowed: feature };
}
return {
allowed: feature.enabled,
limit: feature.limit,
// Remaining logic would fetch from MeteringService in production
remaining: feature.limit
};
}
private calculateSteppedCost(quantity: number, tiers: PricingTier['usageComponents'][0]['tiers']): number {
let remaining = quantity;
let cost = 0;
let previousLimit = 0;
for (const tier of tiers) {
if (remaining <= 0) break;
const tierLimit = tier.upTo ?? Infinity;
const tierQuantity = Math.min(remaining, tierLimit - previousLimit);
cost += (tierQuantity * tier.unitPrice) + tier.flatFee;
remaining -= tierQuantity;
previousLimit = tierLimit;
}
return cost;
}
}
3. Metering Service with Deduplication
Usage metering must handle high concurrency and prevent double-counting. This implementation uses a Redis-backed counter with idempotency keys.
// src/services/metering-service.ts
import Redis from 'ioredis';
export class MeteringService {
private redis: Redis;
constructor(redisUrl: string) {
this.redis = new Redis(redisUrl);
}
/**
* Records a usage event with idempotency.
* Returns the updated aggregate count.
*/
async recordUsage(
customerId: string,
metric: string,
quantity: number,
idempotencyKey: string
): Promise<number> {
const key = `meter:${customerId}:${metric}`;
const idempotencyKeyFull = `idempotent:${idempotencyKey}`;
// Use Redis transaction for atomicity
const result = await this.redis.multi()
.setnx(idempotencyKeyFull, '1') // Check idempotency
.expire(idempotencyKeyFull, 86400) // TTL for idempotency record
.hincrby(key, 'count', 0) // Initialize if missing
.exec();
// If idempotency key already exists, return current count without incrementing
if (result[0][1] === 0) {
const current = await this.redis.hget(key, 'count');
return parseInt(current || '0', 10);
}
// Increment counter
const newCount = await this.redis.hincrby(key, 'count', quantity);
return newCount;
}
async getUsage(customerId: string, metric: string): Promise<number> {
const count = await this.redis.hget(`meter:${customerId}:${metric}`, 'count');
return parseInt(count || '0', 10);
}
}
4. Proration Calculator
Handle billing adjustments when customers change tiers mid-cycle.
// src/engine/proration-calculator.ts
import { PricingTier } from '../schema/pricing-tier.schema';
interface ProrationResult {
creditAmount: number;
chargeAmount: number;
effectiveDate: Date;
}
export class ProrationCalculator {
calculate(
currentTier: PricingTier,
newTier: PricingTier,
cycleStart: Date,
cycleEnd: Date,
changeDate: Date
): ProrationResult {
const totalCycleMs = cycleEnd.getTime() - cycleStart.getTime();
const elapsedMs = changeDate.getTime() - cycleStart.getTime();
const remainingMs = cycleEnd.getTime() - changeDate.getTime();
const elapsedRatio = elapsedMs / totalCycleMs;
const remainingRatio = remainingMs / totalCycleMs;
// Credit for unused portion of current tier
const creditAmount = currentTier.baseFee * remainingRatio;
// Charge for new tier from change date
const chargeAmount = newTier.baseFee * remainingRatio;
return {
creditAmount: parseFloat(creditAmount.toFixed(2)),
chargeAmount: parseFloat(chargeAmount.toFixed(2)),
effectiveDate: changeDate
};
}
}
Pitfall Guide
1. Hardcoding Tier IDs in Feature Flags
Mistake: Embedding tier IDs (e.g., if (user.tier === 'PRO')) directly in feature flag logic.
Impact: Renaming a tier or changing its ID breaks feature access across the application.
Best Practice: Map tier IDs to abstract capability groups (e.g., can_export_reports) in the entitlements layer. Feature flags should check capabilities, not tier names.
2. Ignoring Race Conditions in Metering
Mistake: Using standard database UPDATE statements for usage counters without locking or atomic operations.
Impact: Concurrent requests can overwrite each other, leading to under-counting usage and revenue loss.
Best Practice: Use atomic increment operations (e.g., Redis INCRBY) or optimistic locking with versioning in relational databases. Implement idempotency keys for all metering events.
3. Proration Edge Cases with Trials
Mistake: Applying proration logic to free trials without handling zero-revenue transitions correctly.
Impact: Generating invalid invoice line items or charging customers for time they haven't used yet.
Best Practice: Detect trial status in the proration calculator. If transitioning from trial to paid, calculate the charge based on remaining days only, with zero credit.
4. Feature Gating Drift
Mistake: Relying solely on the payment provider's webhook to update entitlements without a reconciliation process.
Impact: Webhooks can be lost or delayed. A customer may retain access to features after cancellation or lose access prematurely.
Best Practice: Implement a nightly reconciliation job that compares the billing provider's subscription status with the internal entitlement store. Use a "deny-by-default" policy for access checks.
5. Currency and Tax Localization Failures
Mistake: Storing prices as floats and ignoring tax jurisdiction rules.
Impact: Floating-point arithmetic errors accumulate over time. Tax compliance violations can result in fines.
Best Practice: Store all monetary values as integers (cents) or use a decimal library. Offload tax calculation to a specialized service (e.g., Stripe Tax, Avalara) rather than implementing custom tax logic.
6. Over-Engineering Simple Products
Mistake: Building a complex metering engine for a product with a single flat-rate plan.
Impact: Unnecessary infrastructure costs and maintenance overhead.
Best Practice: Use provider-managed subscriptions for simple models. Introduce a custom engine only when the business requires hybrid pricing, complex usage metrics, or frequent tier experimentation.
7. Lack of Audit Trails
Mistake: Not logging changes to pricing configurations or tier assignments.
Impact: Inability to debug billing disputes or track the impact of pricing changes on revenue.
Best Practice: Implement immutable audit logs for all pricing schema changes and tier transitions. Include the actor, timestamp, and diff of changes.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple SaaS (Flat-rate) | Provider-Managed Subscriptions | Low overhead; provider handles billing lifecycle. | Low |
| Usage-Based (API/SaaS) | Custom Metering + Provider Billing | Granular control over metrics; accurate usage tracking. | Medium |
| Enterprise Hybrid | Internal Pricing Engine + Provider | Flexibility for custom contracts; complex proration needs. | High |
| Marketplace/Reseller | Config-Driven Engine with Multi-Tenancy | Supports varied pricing per tenant; dynamic feature gating. | High |
Configuration Template
Use this JSON template to define a hybrid pricing tier in your configuration store.
{
"id": "tier_pro_v2",
"name": "Professional Plan",
"status": "active",
"currency": "usd",
"billingInterval": "monthly",
"baseFee": 4900,
"usageComponents": [
{
"metric": "api_calls",
"tiers": [
{ "upTo": 10000, "unitPrice": 0, "flatFee": 0 },
{ "upTo": 100000, "unitPrice": 0.0005, "flatFee": 0 },
{ "upTo": null, "unitPrice": 0.0003, "flatFee": 0 }
]
},
{
"metric": "storage_gb",
"tiers": [
{ "upTo": 50, "unitPrice": 0, "flatFee": 0 },
{ "upTo": null, "unitPrice": 0.10, "flatFee": 0 }
]
}
],
"features": {
"api_access": true,
"export_reports": { "enabled": true, "limit": 100 },
"priority_support": true,
"custom_integrations": false
},
"prorationStrategy": "immediate",
"metadata": {
"highlight": true,
"badge": "Most Popular"
}
}
Quick Start Guide
-
Initialize Schema Validation:
Install Zod and create pricing-tier.schema.ts. Validate your JSON configuration against this schema on load.
npm install zod
-
Deploy Metering Infrastructure:
Provision a Redis instance. Implement MeteringService using the provided code. Ensure idempotency keys are generated for all client-side usage events.
-
Wire the Pricing Engine:
Instantiate PricingEngine with the loaded tier configuration. Integrate calculateCost into your checkout flow and checkAccess into your API middleware.
-
Test Proration Scenarios:
Use ProrationCalculator to simulate upgrades and downgrades. Verify that credit and charge amounts match expected values for various cycle positions.
-
Monitor and Alert:
Set up alerts for metering failures, reconciliation mismatches, and pricing schema validation errors. Monitor Support Ticket Volume related to billing to validate system stability.