/** * Delivery areas and fees. Eligibility and pricing are deliberately separate: * being inside a zone never implies free delivery. */ import type { DeliveryZone, LocationConfig, Reason } from "./types"; /** Uppercase, strip spaces/dashes. "n2h-5z6" → "N2H5Z6"; "02134-1234" → "021341234". */ export function normalizePostal(postal: string): string { return (postal || "").toUpperCase().replace(/[\s-]/g, ""); } export function postalMatches(postal: string, pattern: string): boolean { const p = normalizePostal(postal); const raw = pattern.trim().toUpperCase(); if (!p || !raw) return false; // Range on a shared-length prefix: "N2A..N2H" or "10001..10010" if (raw.includes("..")) { const [lo, hi] = raw.split("..").map(normalizePostal); if (!lo || lo.length !== hi.length) return false; const head = p.slice(0, lo.length); return head >= lo && head <= hi; } const pat = normalizePostal(raw); if (pat.endsWith("*")) return p.startsWith(pat.slice(0, -1)); return p === pat || (pat.length === 3 && p.startsWith(pat)); // bare Canadian FSA "N2H" } export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { const R = 6371; const toRad = (x: number) => (x * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); } export interface CustomerAddress { postal?: string; lat?: number; lng?: number; /** Pre-computed driving distance per location id, from the maps adapter. */ drivingKm?: Record; } export interface ZoneMatch { zone: DeliveryZone | null; distanceKm: number | null; reason?: Reason; /** True when a zone needed a maps lookup that wasn't available. */ unverified?: boolean; } /** * First matching zone wins (merchant orders zones, typically cheapest/closest first). * A zone matches when ANY of its configured criteria match. * If a driving-distance zone can't be checked (maps outage), it does NOT match — * we never accept unverified addresses. */ export function matchZone(loc: LocationConfig, addr: CustomerAddress): ZoneMatch { const radial = loc.lat != null && loc.lng != null && addr.lat != null && addr.lng != null ? haversineKm(loc.lat, loc.lng, addr.lat, addr.lng) : null; let unverified = false; for (const z of loc.zones) { if (z.postalPatterns?.length && addr.postal && z.postalPatterns.some((pt) => postalMatches(addr.postal!, pt))) return { zone: z, distanceKm: radial }; if (z.radiusKm != null && radial != null && radial <= z.radiusKm) return { zone: z, distanceKm: radial }; if (z.drivingKm != null) { const d = addr.drivingKm?.[loc.id]; if (d == null) unverified = true; else if (d <= z.drivingKm) return { zone: z, distanceKm: d }; } } return { zone: null, distanceKm: radial, unverified, reason: { code: "OUTSIDE_DELIVERY_AREA", message: unverified ? "We couldn't verify your address right now. Please try pickup or check again shortly." : `${loc.name} doesn't deliver to this address yet`, }, }; } export interface FeeQuote { fee: number; baseFee: number; waived: boolean; surcharges: { label: string; amount: number }[]; belowMinimum: boolean; minOrder: number | null; } export function quoteFee( zone: DeliveryZone | null, subtotal: number, extras: { label: string; amount: number }[] = [], ): FeeQuote { const base = zone?.fee ?? 0; const waived = zone?.freeOver != null && subtotal >= zone.freeOver; const surcharges = extras.filter((e) => e.amount > 0); const minOrder = zone?.minOrder ?? null; return { baseFee: base, waived, surcharges, fee: (waived ? 0 : base) + surcharges.reduce((s, e) => s + e.amount, 0), belowMinimum: minOrder != null && subtotal < minOrder, minOrder, }; }