quine-RideRN/server/pricing.mts
Philip Peterson d0fb299f13 Price rides with rate cards, surge and driver share, by market
Dispatch now prices rides with a basic model inspired by public
information on Uber and Lyft (docs/pricing.md): base + per mile + per
minute, times a per-zone surge multiplier from recent requests against
available drivers, plus a booking fee, with a minimum. Riders get an
upfront quote per ride type and book against it; drivers see and earn
a configurable share (75%) of the fare.

Rate cards are per market, chosen by pickup location: San Francisco
(third-party 2026 rates) and Fairbanks, AK (its own UberX and UberXL
rates, Comfort from Anchorage, no Black). Everything is configurable
via PRICING_FILE or POST /test/reset.

Tests use a simple test card by default so auction scenarios keep
their numbers; new scenarios cover SF rates, Fairbanks rates and surge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:47:10 -08:00

148 lines
7.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Pricing model for the dispatch server: a basic model inspired by how Uber and Lyft are
// publicly known to price rides. Their real models are proprietary; this is not them.
//
// fare = max(minimumFare, (baseFare + perMile × miles + perMinute × minutes) × surge + bookingFee)
//
// - Rate cards are per market (a city and its surroundings) and ride type; docs/pricing.md
// has the sources.
// - Surge is per zone: recent ride requests against available drivers nearby.
// - The rider is quoted upfront; the driver earns a fixed share of what the rider pays.
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;
// San Francisco, 2026. UberX is RideWise's March 2026 card (checked against receipts);
// the minimum is TaxiFareFinder's. Uber doesn't publish rate cards any more, and the
// other tiers are estimates scaled from reported national averages and older SF rates.
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 },
};
// Fairbanks, September 2026, from uphail.com. UberX and UberXL are Fairbanks's own rates;
// Fairbanks has no Comfort rates listed, so Comfort is Anchorage's from the same source.
// Neither city lists Black, so it isn't offered there.
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 },
// Includes North Pole and Fort Wainwright.
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,
// About 2 km across in San Francisco.
zoneSizeDegrees: 0.02,
// Uber's surge was measured updating every 5 minutes in 2015; a minute feels closer to today.
intervalMs: 60_000,
demandWindowMs: 10 * 60_000,
// Surge starts once requests outnumber drivers, adding 0.5× per extra request per driver.
threshold: 1,
sensitivity: 0.5,
step: 0.1,
maxMultiplier: 3,
},
// Reported take rates run about 25–29% (Uber); Lyft guarantees drivers at least 70%.
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;
}
/** The market whose rate cards apply to a pickup: the nearest one in range, else the default. */
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));
}
/** The upfront fare for one ride type. */
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);
/** The surge zone a point falls in: a square of the grid, as "row:column". */
export function zoneOf(point: Coord, surge: SurgeConfig): string {
const size = surge.zoneSizeDegrees;
return `${Math.floor(point.latitude / size)}:${Math.floor(point.longitude / size)}`;
}
/** The surge multiplier for a zone with this many recent requests and available drivers. */
export function surgeMultiplier(demand: number, supply: number, surge: SurgeConfig): number {
if (!surge.enabled) return 1;
// With no drivers at all, count as if there were one: every request is unmet demand.
const ratio = demand / Math.max(supply, 1);
const raw = 1 + (ratio - surge.threshold) * surge.sensitivity;
// Round down to the step, with a little slack so 1.5 doesn't become 1.4 from float error.
const stepped = Math.floor((raw + 1e-9) / surge.step) * surge.step;
return round2(Math.min(surge.maxMultiplier, Math.max(1, stepped)));
}