ust be processed idempotently to handle retries and network glitches.
- Caching Strategy: Tier evaluation results should be cached with a short TTL and invalidated immediately upon receiving a webhook event.
2. Technical Implementation
Pricing Schema Definition
Define pricing tiers using a structured schema that supports limits, features, and metering rules.
// types/pricing.ts
import { Decimal } from 'decimal.js';
export type LimitValue = number | 'unlimited';
export interface PricingTier {
id: string;
name: string;
price: Decimal;
currency: string;
interval: 'month' | 'year' | 'usage_based';
limits: Record<string, LimitValue>;
features: string[];
metering?: {
metric: string;
unit: string;
tiered_rates: { up_to: LimitValue; rate: Decimal }[];
};
}
export interface UserSubscription {
userId: string;
tierId: string;
status: 'active' | 'past_due' | 'canceled' | 'trialing';
currentPeriodStart: Date;
currentPeriodEnd: Date;
usage: Record<string, number>;
metadata: Record<string, unknown>;
}
The Pricing Evaluation Engine
The engine evaluates access and calculates costs based on the schema and current user state.
// engine/pricing-engine.ts
import { Decimal } from 'decimal.js';
import { PricingTier, UserSubscription, LimitValue } from '../types/pricing';
export class PricingEngine {
private tiers: Map<string, PricingTier>;
constructor(tiers: PricingTier[]) {
this.tiers = new Map(tiers.map(t => [t.id, t]));
}
hasAccess(subscription: UserSubscription, featureKey: string): boolean {
const tier = this.tiers.get(subscription.tierId);
if (!tier || subscription.status !== 'active') {
return false;
}
return tier.features.includes(featureKey);
}
checkLimit(subscription: UserSubscription, limitKey: string): { allowed: boolean; remaining: number } {
const tier = this.tiers.get(subscription.tierId);
if (!tier) throw new Error('Tier not found');
const limit = tier.limits[limitKey];
const currentUsage = subscription.usage[limitKey] || 0;
if (limit === 'unlimited') {
return { allowed: true, remaining: Infinity };
}
const remaining = limit - currentUsage;
return { allowed: remaining > 0, remaining };
}
calculateOverageCost(subscription: UserSubscription, metric: string, newUsage: number): Decimal {
const tier = this.tiers.get(subscription.tierId);
if (!tier?.metering || tier.metering.metric !== metric) {
return new Decimal(0);
}
const baseUsage = subscription.usage[metric] || 0;
const billableUsage = Math.max(0, newUsage - baseUsage);
let cost = new Decimal(0);
for (const tierRate of tier.metering.tiered_rates) {
if (billableUsage <= 0) break;
const tierLimit = tierRate.up_to === 'unlimited' ? Infinity : tierRate.up_to;
const usageInTier = Math.min(billableUsage, tierLimit - baseUsage);
if (usageInTier > 0) {
cost = cost.plus(new Decimal(usageInTier).times(tierRate.rate));
}
baseUsage += usageInTier;
}
return cost;
}
}
Synchronization Service
Handle webhooks to update local state. This service must verify signatures and manage idempotency.
// services/webhook-handler.ts
import { Request, Response } from 'express';
import { verifyWebhookSignature } from '../utils/crypto';
import { db } from '../db';
import { PricingEngine } from '../engine/pricing-engine';
export async function handlePaymentWebhook(req: Request, res: Response, engine: PricingEngine) {
const signature = req.headers['x-signature'];
const payload = req.body;
if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Idempotency check
const processed = await db.webhookLog.findUnique({ where: { eventId: payload.id } });
if (processed) {
return res.status(200).send('Already processed');
}
try {
await db.webhookLog.create({ data: { eventId: payload.id, status: 'processing' } });
// Update subscription state based on event type
if (payload.type === 'invoice.paid') {
await db.userSubscription.update({
where: { userId: payload.customerId },
data: { status: 'active', currentPeriodEnd: new Date(payload.periodEnd) }
});
} else if (payload.type === 'customer.subscription.updated') {
await db.userSubscription.update({
where: { userId: payload.customerId },
data: { tierId: payload.tierId }
});
}
await db.webhookLog.update({
where: { eventId: payload.id },
data: { status: 'completed' }
});
// Invalidate cache for affected user
await invalidateUserCache(payload.customerId);
res.status(200).send('OK');
} catch (error) {
await db.webhookLog.update({
where: { eventId: payload.id },
data: { status: 'failed', error: error.message }
});
res.status(500).send('Processing error');
}
}
Pitfall Guide
1. Race Conditions on Upgrades
- Mistake: A user initiates an upgrade. The payment succeeds, but the webhook is delayed. The user attempts to access a premium feature immediately and is denied.
- Fix: Implement optimistic updates or a "pending" state in the UI. Alternatively, poll the payment provider API synchronously during the checkout callback to confirm status before redirecting, though this increases latency. The robust approach is to allow access upon successful checkout session completion while the webhook processes asynchronously in the background.
2. Timezone and Billing Cycle Drift
- Mistake: Calculating period boundaries using local time or inconsistent date formats, causing users to be charged early or lose access prematurely.
- Fix: Store all timestamps in UTC. Calculate billing cycle boundaries based on the
created_at timestamp of the subscription plus the interval duration. Never use "end of month" logic unless explicitly required by the business model; use fixed intervals from the anchor date.
3. Floating Point Arithmetic Errors
- Mistake: Using standard JavaScript
number types for currency calculations, leading to precision loss (e.g., 0.1 + 0.2 !== 0.3).
- Fix: Use integer cents for storage and a library like
decimal.js or big.js for all calculations. Convert to decimal only for display.
4. Feature Flag Drift
- Mistake: The frontend checks
user.plan === 'pro' while the backend checks hasAccess(user, 'feature_x'). If the backend logic changes, the frontend may show features the user cannot use.
- Fix: Centralize access checks. The backend must be the authoritative gate. The frontend should query a capability endpoint or receive a capability payload derived from the backend evaluation, rather than making assumptions based on plan names.
5. Cache Invalidation Latency
- Mistake: Caching tier evaluations with a long TTL (e.g., 1 hour). When a user is downgraded or suspended, they retain access until the cache expires.
- Fix: Use a short TTL (e.g., 60 seconds) combined with explicit cache invalidation triggered by webhook processing. Implement a "version" field on the subscription object; if the version in the cache is lower than the database version, force a re-evaluation.
6. Metering Event Loss
- Mistake: Counting usage via synchronous database writes that fail under load, or losing events if the ingestion service restarts.
- Fix: Use an event queue (e.g., Kafka, RabbitMQ, or SQS) for usage ingestion. Process events asynchronously with retry logic. Ensure the ingestion endpoint is idempotent using event IDs to prevent double-counting during retries.
7. Grandfathering Complexity
- Mistake: Ignoring legacy users when changing pricing tiers, causing support issues or revenue loss.
- Fix: Design the pricing engine to support "legacy tier" mapping. When a tier is deprecated, map existing users to a legacy configuration that preserves their old rates and limits, while new users see the updated tier. This requires the tier schema to be immutable once active users are associated with it.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple SaaS with 3 Flat Tiers | DB-Driven Config | Low complexity; sufficient flexibility for minor changes without full engine overhead. | Low implementation cost; medium maintenance. |
| API Product with Usage Billing | Event-Sourced Engine | Required for accurate metering, tiered rates, and handling high-volume usage events. | High initial complexity; low operational cost at scale. |
| Enterprise Custom Contracts | Hybrid Model | Use engine for standard tiers and a custom override layer for negotiated enterprise terms. | Medium implementation; high flexibility for sales. |
| Marketplace with Multi-Seller | Multi-Tenant Pricing Engine | Each seller may define their own tiers; requires isolation and dynamic schema evaluation. | High complexity; essential for platform scalability. |
Configuration Template
Use this JSON structure to define pricing tiers. This template supports flat limits, feature flags, and tiered metering.
{
"version": "2024-05-20",
"tiers": {
"free": {
"id": "free",
"name": "Free",
"price": "0.00",
"currency": "USD",
"interval": "month",
"limits": {
"api_calls": 1000,
"projects": 3,
"storage_mb": 500
},
"features": ["basic_support", "export_csv"],
"metering": null
},
"pro": {
"id": "pro",
"name": "Pro",
"price": "29.00",
"currency": "USD",
"interval": "month",
"limits": {
"api_calls": "unlimited",
"projects": 50,
"storage_mb": 10000
},
"features": ["priority_support", "export_csv", "export_json", "webhooks"],
"metering": {
"metric": "compute_units",
"unit": "CU",
"tiered_rates": [
{ "up_to": 100, "rate": "0.00" },
{ "up_to": 1000, "rate": "0.05" },
{ "up_to": "unlimited", "rate": "0.02" }
]
}
}
}
}
Quick Start Guide
- Initialize Schema: Create the
PricingTier and UserSubscription types in your TypeScript project. Set up the database tables for subscriptions and usage logs.
- Deploy Configuration: Save the pricing configuration JSON to a secure location (e.g., environment variable or config service). Load this into the
PricingEngine on application startup.
- Integrate Evaluation: Replace hardcoded permission checks with calls to
pricingEngine.hasAccess() and pricingEngine.checkLimit(). Wrap these calls in a caching middleware.
- Connect Webhooks: Set up the webhook endpoint in your framework. Configure your payment provider to send events to this endpoint. Implement the signature verification and state update logic.
- Test Flow: Run a test subscription cycle. Verify that a user on the "free" tier is blocked from "pro" features, that usage increments correctly, and that a webhook updates the tier and grants access immediately.
This architecture provides a robust foundation for digital product pricing, ensuring accuracy, flexibility, and scalability as your monetization strategy evolves.