import type { Coord } from '../src/lib/types';
import type { Market, PricingConfig, PricingOverrides, RateCard, SurgeConfig } from '../src/lib/dispatch/protocol';
const METERS_PER_MILE = 1609.344;
const SAN_FRANCISCO: Record<string, RateCard> = {
x: { baseFare: 2.5, perMile: 1.55, perMinute: 0.32, bookingFee: 3, minimumFare: 7 },
comfort: { baseFare: 3, perMile: 1.85, perMinute: 0.38, bookingFee: 3, minimumFare: 9 },
xl: { baseFare: 3, perMile: 2.1, perMinute: 0.4, bookingFee: 3, minimumFare: 9.25 },
black: { baseFare: 8, perMile: 3.75, perMinute: 0.6, bookingFee: 3, minimumFare: 15 },
};
const FAIRBANKS: Record<string, RateCard> = {
x: { baseFare: 2.77, perMile: 1.86, perMinute: 0.31, bookingFee: 2.55, minimumFare: 8.43 },
comfort: { baseFare: 3.74, perMile: 1.92, perMinute: 0.4, bookingFee: 3.58, minimumFare: 11.75 },
xl: { baseFare: 3.25, perMile: 2.25, perMinute: 0.36, bookingFee: 2.55, minimumFare: 8.7 },
};
const MARKETS: Record<string, Market> = {
sf: { name: 'San Francisco', center: { latitude: 37.7749, longitude: -122.4194 }, radiusKm: 60 },
fairbanks: { name: 'Fairbanks', center: { latitude: 64.8378, longitude: -147.7164 }, radiusKm: 60 },
};
export const DEFAULT_PRICING: PricingConfig = {
markets: MARKETS,
defaultMarket: 'sf',
rateCards: { sf: SAN_FRANCISCO, fairbanks: FAIRBANKS },
surge: {
enabled: true,
zoneSizeDegrees: 0.02,
intervalMs: 60_000,
demandWindowMs: 10 * 60_000,
threshold: 1,
sensitivity: 0.5,
step: 0.1,
maxMultiplier: 3,
},
driverShare: 0.75,
quoteTtlMs: 2 * 60_000,
};
export const round2 = (n: number) => Math.round(n * 100) / 100;
export function mergePricing(base: PricingConfig, overrides: PricingOverrides = {}): PricingConfig {
const rateCards: PricingConfig['rateCards'] = {};
for (const market of new Set([...Object.keys(base.rateCards), ...Object.keys(overrides.rateCards ?? {})])) {
rateCards[market] = { ...base.rateCards[market] };
for (const [type, card] of Object.entries(overrides.rateCards?.[market] ?? {})) {
rateCards[market][type] = { ...rateCards[market][type], ...card } as RateCard;
}
}
return {
markets: { ...base.markets, ...overrides.markets },
defaultMarket: overrides.defaultMarket ?? base.defaultMarket,
rateCards,
surge: { ...base.surge, ...overrides.surge },
driverShare: overrides.driverShare ?? base.driverShare,
quoteTtlMs: overrides.quoteTtlMs ?? base.quoteTtlMs,
};
}
export function validatePricing(p: PricingConfig): string | null {
for (const [market, cards] of Object.entries(p.rateCards)) {
for (const [type, card] of Object.entries(cards)) {
for (const [field, value] of Object.entries(card)) {
if (!(typeof value === 'number' && value >= 0)) return `rateCards.${market}.${type}.${field} must be a number ≥ 0`;
}
}
}
for (const [id, market] of Object.entries(p.markets)) {
if (!p.rateCards[id]) return `markets.${id} has no rate cards`;
if (!(market.radiusKm > 0)) return `markets.${id}.radiusKm must be more than 0`;
}
if (!p.markets[p.defaultMarket]) return `defaultMarket "${p.defaultMarket}" isn't one of the markets`;
if (!(p.driverShare > 0 && p.driverShare <= 1)) return 'driverShare must be more than 0 and at most 1';
if (!(p.quoteTtlMs > 0)) return 'quoteTtlMs must be more than 0';
const s = p.surge;
if (!(s.zoneSizeDegrees > 0)) return 'surge.zoneSizeDegrees must be more than 0';
if (!(s.step > 0)) return 'surge.step must be more than 0';
if (!(s.maxMultiplier >= 1)) return 'surge.maxMultiplier must be at least 1';
return null;
}
export function marketFor(pickup: Coord, pricing: PricingConfig): string {
let best: { id: string; km: number } | null = null;
for (const [id, market] of Object.entries(pricing.markets)) {
const km = distanceKm(pickup, market.center);
if (km <= market.radiusKm && (!best || km < best.km)) best = { id, km };
}
return best?.id ?? pricing.defaultMarket;
}
function distanceKm(a: Coord, b: Coord): number {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(b.latitude - a.latitude);
const dLon = toRad(b.longitude - a.longitude);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.latitude)) * Math.cos(toRad(b.latitude)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.sqrt(h));
}
export function fareFor(card: RateCard, distanceMeters: number, durationSeconds: number, surge: number): number {
const miles = distanceMeters / METERS_PER_MILE;
const minutes = durationSeconds / 60;
const ride = card.baseFare + card.perMile * miles + card.perMinute * minutes;
return round2(Math.max(card.minimumFare, ride * surge + card.bookingFee));
}
export const earningsFor = (fare: number, pricing: PricingConfig) => round2(fare * pricing.driverShare);
export function zoneOf(point: Coord, surge: SurgeConfig): string {
const size = surge.zoneSizeDegrees;
return `${Math.floor(point.latitude / size)}:${Math.floor(point.longitude / size)}`;
}
export function surgeMultiplier(demand: number, supply: number, surge: SurgeConfig): number {
if (!surge.enabled) return 1;
const ratio = demand / Math.max(supply, 1);
const raw = 1 + (ratio - surge.threshold) * surge.sensitivity;
const stepped = Math.floor((raw + 1e-9) / surge.step) * surge.step;
return round2(Math.min(surge.maxMultiplier, Math.max(1, stepped)));
}