datewise/app/engine/zones.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

115 lines
3.9 KiB
TypeScript

/**
* 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<string, number | null>;
}
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,
};
}