Core Solution
Step-by-Step Implementation
To support dynamic, scalable pricing, you must decouple Metering, Rating, Entitlements, and Invoicing.
1. Define the Pricing Schema
Create a domain model that represents pricing as data, not logic.
- Plan: Defines the container (e.g.,
pro, enterprise).
- Feature: Granular capability (e.g.,
api_calls, storage_gb).
- Meter: The unit of measurement and aggregation logic.
- Rating Rule: How the meter translates to cost (e.g., tiered, volume, per-unit).
2. Implement the Metering Service
This service ingests usage events. It must be high-throughput, idempotent, and durable.
- Ingestion: Accept events via HTTP/gRPC or message queues.
- Validation: Verify tenant existence and feature entitlements.
- Storage: Write to a time-series database or append-only log.
- Idempotency: Use unique event IDs to prevent double-counting.
3. Build the Rating Engine
The rating engine calculates costs based on metered data and pricing rules.
- Batch vs. Real-Time: Use batch processing for invoice generation (daily/hourly) and real-time calculation for usage alerts.
- Algorithm: Implement support for tiers, step-pricing, and committed spend.
- Currency Handling: Normalize all values to a base currency before rating.
4. Entitlements Service
Gate access based on the current plan and usage limits.
- Cache: Entitlement checks must be O(1). Cache plan features in Redis/Memcached.
- Fallback: If the entitlement service is down, default to "allow" with a grace period to avoid blocking legitimate traffic, or "deny" for critical resources, depending on risk appetite.
5. Integration with Payment Provider
Abstract the payment provider. Use a billing adapter pattern.
- Sync: Push metered usage to the provider (e.g., Stripe Usage Records) or generate invoices locally.
- Reconciliation: Daily reconciliation jobs to ensure local metering matches provider records.
Architecture Decisions
- Event Sourcing for Metering: Store raw events and replay them for rating. This allows you to fix rating logic without losing data history.
- OLAP for Analytics: Use a columnar store (e.g., ClickHouse, BigQuery) for billing analytics and customer usage dashboards. OLTP databases will choke on aggregating millions of metering events.
- Idempotency Keys: Enforce idempotency at the ingestion layer. This is non-negotiable for accurate billing.
- Circuit Breakers: Protect the rating engine from cascading failures. If the payment provider is down, queue invoices and retry.
Code Examples
Metering Event Schema (TypeScript)
interface MeteringEvent {
id: string; // UUID v4
tenantId: string;
featureKey: string; // e.g., "api_calls"
quantity: number;
timestamp: Date;
idempotencyKey: string; // Client-generated key
metadata?: Record<string, string>; // e.g., { region: "us-east-1" }
}
// Ingestion Handler with Idempotency Check
async function ingestEvent(event: MeteringEvent): Promise<void> {
const exists = await redis.exists(`idempotency:${event.idempotencyKey}`);
if (exists) {
return; // Duplicate event, ignore
}
// Validate tenant entitlements
const isAllowed = await entitlements.check(event.tenantId, event.featureKey);
if (!isAllowed) {
throw new ForbiddenError('Feature not enabled for tenant');
}
// Write to time-series store
await timescale.insert(event);
// Set idempotency key with TTL
await redis.set(`idempotency:${event.idempotencyKey}`, '1', 'EX', 86400);
}
Rating Engine Logic (Volume Pricing)
interface RatingRule {
model: 'volume' | 'tiered' | 'per_unit';
tiers: { upTo: number | null; rate: number }[];
}
function calculateCost(quantity: number, rule: RatingRule): number {
if (rule.model === 'per_unit') {
return quantity * rule.tiers[0].rate;
}
if (rule.model === 'volume') {
// Volume pricing: The highest tier applies to all units
const applicableTier = rule.tiers.find(t => t.upTo === null || quantity <= t.upTo);
if (!applicableTier) throw new Error('No matching tier');
return quantity * applicableTier.rate;
}
if (rule.model === 'tiered') {
// Tiered pricing: Different rates for different chunks
let cost = 0;
let remaining = quantity;
let prevLimit = 0;
for (const tier of rule.tiers) {
if (remaining <= 0) break;
const limit = tier.upTo ?? Infinity;
const chunkSize = Math.min(limit - prevLimit, remaining);
cost += chunkSize * tier.rate;
remaining -= chunkSize;
prevLimit = limit;
}
return cost;
}
return 0;
}
Pitfall Guide
5-7 Common Mistakes
- Floating Point Arithmetic Errors: Never use
float or double for currency. Use decimal libraries or store values as integers (e.g., cents or milliunits). Floating point errors cause rounding discrepancies that compound over millions of transactions.
- Ignoring Timezone Cutoffs: Billing cycles often reset at midnight UTC. If your metering uses local time, customers in different timezones will have misaligned billing periods. Fix: Normalize all timestamps to UTC at ingestion.
- Race Conditions in Metering: If a client sends two events simultaneously, and your increment logic is
GET -> ADD -> SET, you will lose data. Fix: Use atomic increments in your database or append-only logs.
- Lack of Proration Logic: When a customer upgrades mid-cycle, you must calculate the credit for the remaining days of the old plan and charge for the new plan. Hardcoded proration is brittle. Fix: Implement a proration engine that calculates daily rates based on the cycle duration.
- Single Point of Failure in Entitlements: If your entitlement service goes down, you might block all API traffic. Fix: Implement a cache-aside pattern with a stale-data fallback strategy. Allow requests to proceed for a short window if the cache is stale, while alerting engineers.
- Neglecting Idempotency on the Client Side: Clients may retry requests due to network issues. If your backend doesn't handle idempotency, you will double-charge. Fix: Require idempotency keys on all metering requests and enforce them rigorously.
- Currency Conversion Drift: If you bill in USD but have customers in EUR, exchange rates fluctuate. Fix: Lock the exchange rate at the time of the invoice generation or use a provider that handles FX risk. Never calculate FX manually without a reliable feed.
Production Bundle
Action Checklist
Decision Matrix
| Criteria | Stripe Billing | Custom Metering Engine | Usage-Based Platforms (e.g., Meter, ProfitWell) |
|---|
| Complexity | Low | High | Medium |
| Customization | Low (Provider constraints) | Unlimited | High |
| Cost | Transaction fees + % | Infrastructure + Dev time | SaaS fee + % |
| Time-to-Value | Immediate | Weeks/Months | Days |
| Best For | Standard SaaS, Early Stage | Enterprise, Complex Hybrid, High Volume | Growth Stage, Usage-Based Focus |
| Data Ownership | Provider controls | You control | You control |
Configuration Template
Use this JSON schema to define pricing plans dynamically. This can be stored in a database or config service and consumed by the rating engine.
{
"planId": "enterprise_v2",
"currency": "USD",
"features": [
{
"key": "api_calls",
"meteringType": "aggregated",
"rating": {
"model": "volume",
"tiers": [
{ "upTo": 100000, "rate": 0.0005 },
{ "upTo": 500000, "rate": 0.0004 },
{ "upTo": null, "rate": 0.0003 }
]
},
"entitlement": {
"type": "hard_limit",
"limit": 1000000,
"actionOnExceed": "throttle_and_notify"
}
},
{
"key": "storage_gb",
"meteringType": "snapshot",
"rating": {
"model": "per_unit",
"rate": 0.10
}
}
],
"commitments": [
{
"type": "minimum_spend",
"amount": 5000.00,
"period": "monthly"
}
]
}
Quick Start Guide
- Initialize Metering Store: Set up a time-series database (e.g., TimescaleDB or ClickHouse). Create a table for
usage_events with columns for tenant_id, feature_key, quantity, timestamp, and idempotency_key. Add a unique constraint on idempotency_key.
- Deploy Ingestion API: Create a lightweight API endpoint
/v1/meter that accepts events. Implement the idempotency check using Redis. Write events to the store asynchronously via a message queue (e.g., Kafka or SQS) to decouple ingestion from storage.
- Build Rating Job: Create a scheduled job that runs hourly. It queries the metering store for the last hour's events, aggregates by tenant and feature, and applies rating rules from the configuration template. Store the calculated cost in a
billing_ledger table.
- Sync to Provider: Implement a connector that pushes usage records to your payment provider. Use the provider's API to update customer invoices. Ensure you handle API rate limits and retries.
- Verify and Monitor: Run a parallel test: meter real traffic but do not charge. Compare the calculated costs against expected values. Once validated, enable charging. Set up dashboards for ingestion rates, rating lag, and revenue totals.
Conclusion
Engineering SaaS pricing is not just about calculating costs; it is about building a revenue infrastructure that is scalable, accurate, and agile. By adopting an engine-driven architecture, you transform pricing from a static constraint into a dynamic growth lever. The technical investment in metering, rating, and idempotency pays dividends in reduced churn, increased revenue capture, and the ability to experiment with business models at the speed of market demand.
Next Steps: Review your current billing architecture against the Pitfall Guide. If you are using hardcoded pricing, prioritize the migration to a configuration-driven schema. The cost of inaction is revenue leakage and strategic rigidity.