Designing a pricing engine for transportation platforms
When you're running a ride-hailing or transportation service, pricing isn't just about multiplying distance by a flat rate. Designing a pricing engine means building a system flexible enough to handle dozens of pricing rules simultaneously—surge pricing, vehicle classes, tolls, airport premiums—while keeping every quote transparent and auditable. Over 5 years building platforms like this, I've learned that the difference between a fragile pricing system and a robust one comes down to strategy selection, composable modifiers, and treating pricing rules as configurable data instead of hardcoded logic.
Let me walk you through how to architect this properly using the Strategy and Decorator patterns combined with a rules engine approach.
Base strategies: Distance, hourly, and fixed pricing
Every transportation service needs at least one base pricing model. Most support three: distance-based (Colombo to Negombo), hourly (airport waits), and fixed fares (airport transfers). These are your strategy implementations.
Distance-based works for point-to-point rides: 12 km at 150 LKR per km. Hourly applies when the vehicle sits idle: waiting at an airport, or a long-distance trip charged by the hour. Fixed fares eliminate customer uncertainty for common routes—Bandaranaike International to Fort is always 5,500 LKR.
Here's how I structure the base interface:
interface PricingStrategy {
calculateBase(input: PricingInput): number;
getType(): string;
}
interface PricingInput {
distanceKm: number;
durationMinutes: number;
vehicleClass: 'economy' | 'comfort' | 'premium';
routeId?: string; // for fixed fares
pickupLocation: Coordinates;
dropoffLocation: Coordinates;
}
class DistanceStrategy implements PricingStrategy {
constructor(private ratePerKm: number) {}
calculateBase(input: PricingInput): number {
return input.distanceKm * this.ratePerKm;
}
getType(): string {
return 'distance';
}
}
class HourlyStrategy implements PricingStrategy {
constructor(private ratePerHour: number) {}
calculateBase(input: PricingInput): number {
const hours = input.durationMinutes / 60;
return hours * this.ratePerHour;
}
getType(): string {
return 'hourly';
}
}
class FixedFareStrategy implements PricingStrategy {
constructor(private routeId: string, private fixedPrice: number) {}
calculateBase(input: PricingInput): number {
if (input.routeId === this.routeId) {
return this.fixedPrice;
}
return 0;
}
getType(): string {
return 'fixed';
}
}In a real system, you'd select the appropriate strategy based on the request. For example, if both pickup and dropoff match a defined fixed-fare route, use FixedFareStrategy. Otherwise, fall back to distance-based.
Modifiers: Composable rules for dynamic pricing
Base price is only the start. A ride at 2 AM costs more. Peak hours cost more. Premium vehicle class costs more. Tolls add to the total. These are modifiers—multiplicative or additive rules applied after the base calculation.
I treat modifiers as a chain of responsibility, where each modifier examines the pricing context and decides whether it applies:
interface PricingModifier {
apply(basePrice: number, context: PricingContext): PricingModifierResult;
}
interface PricingContext {
timestamp: Date;
distanceKm: number;
vehicleClass: string;
pickupLocation: Coordinates;
dropoffLocation: Coordinates;
demandLevel: number; // 1.0 = baseline, 1.5 = 50% surge
}
interface PricingModifierResult {
finalPrice: number;
modifierName: string;
factor: number; // 1.0 = no change, 1.25 = 25% increase
appliedAmount: number;
reason: string;
}
class NightSurchargeModifier implements PricingModifier {
apply(basePrice: number, context: PricingContext): PricingModifierResult {
const hour = context.timestamp.getHours();
const isNight = hour >= 22 || hour < 5;
if (isNight) {
const factor = 1.25; // 25% increase
return {
finalPrice: basePrice * factor,
modifierName: 'Night Surcharge',
factor,
appliedAmount: basePrice * (factor - 1),
reason: `${hour}:00 - Night rates apply (22:00-05:00)`
};
}
return {
finalPrice: basePrice,
modifierName: 'Night Surcharge',
factor: 1.0,
appliedAmount: 0,
reason: 'Daytime rates apply'
};
}
}
class PeakTimeModifier implements PricingModifier {
apply(basePrice: number, context: PricingContext): PricingModifierResult {
// Peak: weekday 8-10 AM and 5-7 PM
const day = context.timestamp.getDay();
const hour = context.timestamp.getHours();
const isWeekday = day >= 1 && day <= 5;
const isPeakHour = (hour >= 8 && hour < 10) || (hour >= 17 && hour < 19);
if (isWeekday && isPeakHour) {
const factor = 1.4;
return {
finalPrice: basePrice * factor,
modifierName: 'Peak Time Surcharge',
factor,
appliedAmount: basePrice * (factor - 1),
reason: `${hour}:00 on weekday - Peak demand surcharge`
};
}
return {
finalPrice: basePrice,
modifierName: 'Peak Time Surcharge',
factor: 1.0,
appliedAmount: 0,
reason: 'Off-peak rates apply'
};
}
}
class VehicleClassModifier implements PricingModifier {
private classMultipliers: Record = {
'economy': 1.0,
'comfort': 1.3,
'premium': 1.8
};
apply(basePrice: number, context: PricingContext): PricingModifierResult {
const factor = this.classMultipliers[context.vehicleClass] || 1.0;
return {
finalPrice: basePrice * factor,
modifierName: 'Vehicle Class',
factor,
appliedAmount: basePrice * (factor - 1),
reason: `${context.vehicleClass} vehicle selected`
};
}
}
class TollModifier implements PricingModifier {
constructor(private tollCalculator: TollCalculator) {}
apply(basePrice: number, context: PricingContext): PricingModifierResult {
const tollAmount = this.tollCalculator.estimate(
context.pickupLocation,
context.dropoffLocation
);
return {
finalPrice: basePrice + tollAmount,
modifierName: 'Tolls',
factor: 1.0,
appliedAmount: tollAmount,
reason: tollAmount > 0 ? `Highway toll: ${tollAmount} LKR` : 'No tolls on this route'
};
}
}Each modifier is independent and stateless. Chain them together in a PricingEngine that applies them in order and tracks the breakdown:
class PricingEngine {
constructor(
private baseStrategy: PricingStrategy,
private modifiers: PricingModifier[]
) {}
quote(input: PricingInput): PriceQuote {
const basePrice = this.baseStrategy.calculateBase(input);
const context: PricingContext = { ...input, timestamp: new Date() };
let currentPrice = basePrice;
const breakdown: PricingBreakdown[] = [
{
component: this.baseStrategy.getType().toUpperCase(),
amount: basePrice,
factor: 1.0
}
];
for (const modifier of this.modifiers) {
const result = modifier.apply(currentPrice, context);
breakdown.push({
component: result.modifierName,
amount: result.appliedAmount,
factor: result.factor,
reason: result.reason
});
currentPrice = result.finalPrice;
}
return {
estimatedFare: Math.round(currentPrice),
breakdown,
currency: 'LKR',
calculatedAt: new Date(),
expiresAt: new Date(Date.now() + 5 * 60 * 1000) // Valid for 5 min
};
}
}
interface PriceQuote {
estimatedFare: number;
breakdown: PricingBreakdown[];
currency: string;
calculatedAt: Date;
expiresAt: Date;
}
interface PricingBreakdown {
component: string;
amount: number;
factor: number;
reason?: string;
}Rules as data: Admin configuration without redeployment
Hardcoding modifiers means every pricing change requires a code review and deployment. Instead, load modifier configurations from a database or configuration service. Your admin team updates rates in real time, and the pricing engine respects them immediately.
Here's a simple database schema approach:
| Column | Type | Example |
|---|---|---|
| id | UUID | modifier-night-001 |
| name | string | Night Surcharge |
| type | enum | time_based | class_based | demand_based | fixed |
| factor | decimal | 1.25 |
| conditions | JSON | {"startHour": 22, "endHour": 5} |
| enabled | boolean | true |
| priority | int | 10 |
| createdAt | timestamp | 2024-01-15 10:30:00 |
Load and cache these rules in memory, refreshing periodically (or via event notification):
class ConfigurablePricingEngine {
private modifiers: PricingModifier[] = [];
private lastRefresh = 0;
private refreshInterval = 60000; // 1 minute
constructor(
private baseStrategy: PricingStrategy,
private configService: ConfigService
) {
this.loadModifiers();
}
private async loadModifiers(): Promise {
const configs = await this.configService.getModifierConfigs();
this.modifiers = configs
.filter(c => c.enabled)
.sort((a, b) => a.priority - b.priority)
.map(c => this.buildModifierFromConfig(c));
}
private buildModifierFromConfig(config: ModifierConfig): PricingModifier {
switch (config.type) {
case 'time_based':
return new DynamicTimeModifier(config.name, config.factor, config.conditions);
case 'class_based':
return new DynamicClassModifier(config.conditions);
case 'demand_based':
return new DemandModifier(config.factor);
default:
throw new Error(`Unknown modifier type: ${config.type}`);
}
}
async quote(input: PricingInput): Promise {
// Refresh modifiers if stale
if (Date.now() - this.lastRefresh > this.refreshInterval) {
await this.loadModifiers();
this.lastRefresh = Date.now();
}
const basePrice = this.baseStrategy.calculateBase(input);
let currentPrice = basePrice;
const breakdown: PricingBreakdown[] = [];
for (const modifier of this.modifiers) {
const result = modifier.apply(currentPrice, { ...input, timestamp: new Date() });
breakdown.push({
component: result.modifierName,
amount: result.appliedAmount,
factor: result.factor,
reason: result.reason
});
currentPrice = result.finalPrice;
}
return {
estimatedFare: Math.round(currentPrice),
breakdown,
currency: 'LKR',
calculatedAt: new Date(),
expiresAt: new Date(Date.now() + 5 * 60 * 1000)
};
}
}Explainable quotes: Transparency in every calculation
Users shouldn't see a price and wonder why it's so high. Every quote must show the breakdown: base fare + night surcharge + peak surcharge + toll = total. This is also critical for support—when a customer disputes a charge, you can immediately show them exactly how it was calculated.
Store the full breakdown with every quote:
interface StoredQuote {
id: string;
rideId: string;
estimatedFare: number;
actualFare: number;
breakdown: PricingBreakdown[];
displayText: string; // Human-readable breakdown for the app
calculatedAt: Date;
acceptedAt?: Date;
appliedAt?: Date; // When the actual charge was made
}
function formatQuoteForDisplay(quote: PriceQuote): string {
let text = `Estimated fare: ${quote.estimatedFare} ${quote.currency}\n\n`;
text += 'Breakdown:\n';
for (const line of quote.breakdown) {
if (line.amount > 0) {
text += `• ${line.component}: +${line.amount} (${(line.factor * 100).toFixed(0)}%)\n`;
} else if (line.reason) {
text += `• ${line.component}: ${line.reason}\n`;
}
}
return text;
}When a ride completes, calculate the actual fare using the same engine but with real data (actual distance, actual duration, actual route):
class RideCharging {
constructor(private pricingEngine: ConfigurablePricingEngine) {}
async chargeRide(ride: CompletedRide): Promise {
const actualInput: PricingInput = {
distanceKm: ride.actualDistanceKm,
durationMinutes: ride.actualDurationMinutes,
vehicleClass: ride.vehicleClass,
pickupLocation: ride.pickupCoordinates,
dropoffLocation: ride.dropoffCoordinates
};
const actualQuote = await this.pricingEngine.quote(actualInput);
// Sometimes actual price differs from estimate (longer traffic jam, detour)
const difference = actualQuote.estimatedFare - ride.estimatedFare;
return {
rideId: ride.id,
estimatedFare: ride.estimatedFare,
actualFare: actualQuote.estimatedFare,
breakdown: actualQuote.breakdown,
chargeAmount: actualQuote.estimatedFare,
adjustment: difference > 0 ? 'additional charge' : 'refund',
adjustmentAmount: Math.abs(difference)
};
}
}Common mistakes
- Applying modifiers multiplicatively without cap: If you stack night surcharge (1.25×) + peak (1.4×) + demand (1.5×), you get 2.625× the base fare. Users see it as unreasonable and dispute. Always cap the final multiplier or apply demand as an additive adjustment instead.
- Not versioning pricing rules: An admin changes the night surcharge factor from 1.25 to 1.5, and now you can't explain why the same trip cost differently yesterday. Version your modifier configurations and reference the version in the quote.
- Forgetting time zone differences: If your platform spans multiple regions, "peak hours 8-10 AM" means something different in each zone. Store times as UTC and convert client-side, or make peak times configurable per region.
- Calculating base price with floating point: Use integers for all monetary calculations to avoid rounding errors that accumulate. Store prices in cents, not dollars.
- No audit trail for configuration changes: When an admin updates a modifier, you have no way to know if a dispute three days later relates to that change. Log every configuration change with timestamp, user, old value, new value.
Key takeaways
- Separate base pricing strategies (distance, hourly, fixed) from modifiers (night, peak, vehicle class). Each strategy is independent; each modifier is composable.
- Treat pricing rules as configurable data, not hardcoded logic. Load from a database and refresh periodically so admins can adjust rates without deployments.
- Always store the breakdown with every quote and actual charge. This transparency is worth more than the extra database space—it saves support time and builds customer trust.
- Apply modifiers in a defined priority order and cap the total multiplier to avoid compounding surprises. Integer arithmetic and careful rounding prevent financial disputes.
- Version your pricing configurations so you can audit and explain historical charges. Every quote is a data point; every configuration change is a fact that might be important later.






Comments
Loading comments…