Files
waseel/lib/pricing.ts
T
KrikoriosandClaude f50ff27e11 Build driver app, Uber-style dispatch, POI suggestions; fix map tiles
Driver side (was a stub):
- In-app driver onboarding: a driver-role user creates their own linked
  drivers profile (driver/profile+api GET/POST/PATCH).
- Driver dashboard: online/offline toggle, today's earnings, incoming
  request cards (accept/decline), active ride panel (start/complete trip).
  Polls /driver/rides every 4s while online.
- Location heartbeat (use-driver-location): watchPositionAsync pings
  /driver/location every ~5s; restarts the watch on app foreground so a
  backgrounded driver doesn't go permanently stale and miss requests.

Dispatch (auto-match nearest, Uber-style):
- Ride state machine: requested -> accepted -> en_route -> completed/cancelled
  with a nullable driver_id until matched (lib/dispatch.matchNextDriver).
- matchNextDriver locks the ride (SELECT FOR UPDATE), expires 15s-stale
  offers, picks the nearest eligible driver of the matching service by
  haversine, offers one at a time. Called from ride/create, ride/[id] GET
  (lazy match on the rider's poll), and ride/[id]/respond (on decline).
- ride/create is now a request endpoint (driver_id NULL, status=requested,
  service); drops the pre-match driver_id payment reconciliation.
- ride/[id] GET returns status/service/nullable driver; PATCH handles rider
  cancel + driver en_route/completed. ride/list backs the history tabs.

Rider flow (best experience):
- confirm-ride is now a request screen: single trip fare + nearest-driver
  ETA + cash/card + Request Ride -> live status. Periodically polls online
  drivers of the selected service and disables Request when none are
  online (prevents the "stuck searching forever" state).
- book-ride is the live ride-status screen (searching -> accepted ->
  en_route -> completed/cancelled + Cancel), polling every 3s.
- lib/request-ride unifies the Areeba card flow + cash path.
- Map reads /driver/nearby (real positions, service-filtered); lib/map
  adds calculateTripFare + service-aware fares.

POI suggestions:
- lib/places (Google Nearby Search) + nearby-suggestions chips for
  mall/hospital/pharmacy/restaurant on the home screen.

Service categories now drive both matching and a per-service fare
multiplier (car 1.0 / moto 0.7 / courier 0.85 / chauffeur 1.5).

Map tiles: react-native-maps rendered blank on Android because no Google
Maps key was set. Switched app.json -> app.config.js so
android.config.googleMaps.apiKey is injected from
EXPO_PUBLIC_GOOGLE_API_KEY at build time (keeps the key out of git).
Requires a native rebuild (expo run:android) to take effect.

Also includes the prior payment/auth hardening (server-authoritative
payment_orders ledger with double-spend guards, peppered OTP, register
TOCTOU fix, stats cents fix) that was left uncommitted.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 13:57:21 +03:00

43 lines
1.4 KiB
TypeScript
Raw 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.
// Fare model tuned to the Lebanese market:
// - Distance-based with a base fare (like CTaxi/local taxis), not time-only.
// - Typical Beirut rides land in the $38 range riders expect.
// - Prices are quoted in USD (the de facto ride-hailing currency) with an
// L.B.P. equivalent shown for cash settlement.
import { DEFAULT_SERVICE, SERVICES, type ServiceId } from "@/constants/services";
export const FARE = {
base: 1.5, // USD, flag drop
perKm: 0.55, // USD per kilometer of the trip
perMin: 0.15, // USD per minute of the trip
minimum: 3.0, // USD minimum fare
} as const;
// Parallel market rate used for the L.B.P. cash equivalent shown in-app.
export const LBP_RATE = 89500;
export const calculateFare = (
{
distanceMeters,
durationSeconds,
}: {
distanceMeters: number;
durationSeconds: number;
},
service: ServiceId = DEFAULT_SERVICE,
): string => {
const km = distanceMeters / 1000;
const minutes = durationSeconds / 60;
const multiplier =
SERVICES.find((s) => s.id === service)?.fareMultiplier ?? 1;
const fare = (FARE.base + km * FARE.perKm + minutes * FARE.perMin) * multiplier;
// The minimum fare is a floor on the final amount.
return Math.max(fare, FARE.minimum).toFixed(2);
};
// Rounds to the nearest 1,000 L.B.P. — the smallest practical cash note.
export const formatLBP = (usd: number): string =>
`${(Math.round((usd * LBP_RATE) / 1000) * 1000).toLocaleString("en-US")} L.B.P.`;