/** * Capacity, holds and bookings. * * `MemoryReservationStore` is the reference implementation of the reservation * semantics. The production store (app/services/reservations.server.ts) implements * the same contract on Postgres with row-level locking; both are covered by the * same behavioural tests. * * Capacity unit (v1): orders, not items. */ import type { Booking, LocationConfig, Method, PeakDay, UsageSnapshot } from "./types"; import { peakFor, usageKey } from "./availability"; export interface Caps { dayCap: number | null; slotCap: number | null; } export function capsFor( loc: LocationConfig, method: Method, date: string, slot: string, peaks: PeakDay[], ): Caps { const cfg = loc.methods[method]; const peak = peakFor(peaks, loc.id, method, date); let tplCap: number | null | undefined; if (cfg?.slotMode === "explicit") { const [start, end] = slot.split("-"); for (const list of Object.values(cfg.slots ?? {})) for (const s of list ?? []) if (s.start === start && s.end === end && s.capacity != null) tplCap = s.capacity; } return { dayCap: peak?.dailyCapacity ?? cfg?.dailyCapacity ?? null, slotCap: peak?.slotCapacity ?? tplCap ?? cfg?.slotCapacity ?? null, }; } export interface Selection { locationId: string; method: Method; date: string; slot: string; // "HH:MM-HH:MM" } export interface Hold extends Selection { token: string; expiresAt: number; } export type HoldResult = | { ok: true; hold: Hold } | { ok: false; code: "DAY_FULL" | "SLOT_FULL"; message: string }; export type ConfirmResult = | { ok: true; booking: Booking; duplicate: boolean; reReserved?: boolean } | { ok: false; booking: Booking; code: "NEEDS_REVIEW"; message: string }; export interface WaitlistEntry extends Selection { id: string; contact: { email?: string; phone?: string; name?: string }; createdAt: number; notifiedAt?: number; } export class MemoryReservationStore { holds = new Map(); bookings = new Map(); waitlist: WaitlistEntry[] = []; audit: { at: number; actor: string; bookingId: string; action: string; before?: unknown; after?: unknown }[] = []; private seq = 0; private counts(sel: Selection, now: number, excludeToken?: string, excludeBooking?: string) { let day = 0; let slot = 0; const same = (s: Selection) => s.locationId === sel.locationId && s.method === sel.method && s.date === sel.date; for (const b of this.bookings.values()) { // Only confirmed bookings consume capacity; flagged ones wait for a merchant decision. if (b.id === excludeBooking || b.status !== "confirmed") continue; const s = { locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` }; if (same(s)) { day++; if (s.slot === sel.slot) slot++; } } for (const h of this.holds.values()) { if (h.token === excludeToken || h.expiresAt <= now) continue; if (same(h)) { day++; if (h.slot === sel.slot) slot++; } } return { day, slot }; } usage(now: number): UsageSnapshot { const out: UsageSnapshot = {}; const add = (s: Selection) => { const k = usageKey(s.locationId, s.method, s.date); out[k] ??= { day: 0, slots: {} }; out[k].day++; out[k].slots[s.slot] = (out[k].slots[s.slot] ?? 0) + 1; }; for (const b of this.bookings.values()) if (b.status === "confirmed") add({ locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` }); for (const h of this.holds.values()) if (h.expiresAt > now) add(h); return out; } private fits(sel: Selection, caps: Caps, now: number, excludeToken?: string, excludeBooking?: string) { const c = this.counts(sel, now, excludeToken, excludeBooking); if (caps.dayCap != null && c.day >= caps.dayCap) return "DAY_FULL" as const; if (caps.slotCap != null && c.slot >= caps.slotCap) return "SLOT_FULL" as const; return null; } /** Atomic check-and-reserve. Re-holding with the same token moves the hold. */ hold(token: string, sel: Selection, caps: Caps, now: number, holdMinutes: number): HoldResult { const full = this.fits(sel, caps, now, token); if (full) return { ok: false, code: full, message: full === "DAY_FULL" ? "Fully booked for this day" : "This time slot was just taken" }; const hold: Hold = { ...sel, token, expiresAt: now + holdMinutes * 60000 }; this.holds.set(token, hold); return { ok: true, hold }; } release(token: string) { this.holds.delete(token); } sweep(now: number): number { let n = 0; for (const [t, h] of this.holds) if (h.expiresAt <= now) (this.holds.delete(t), n++); return n; } /** * Confirm a hold against an order. Idempotent per orderId (duplicate webhooks are safe). * Expired hold: re-reserve if capacity still allows, otherwise flag for review — * never silently overbook. */ confirm( orderId: string, token: string | null, fallbackSel: Selection | null, caps: Caps, now: number, details: Partial & { timezone: string }, ): ConfirmResult { const existing = [...this.bookings.values()].find((b) => b.orderId === orderId); if (existing) return { ok: true, booking: existing, duplicate: true }; const hold = token ? this.holds.get(token) : undefined; const sel = hold ?? fallbackSel; const id = `bk_${++this.seq}`; const base = (status: Booking["status"]): Booking => ({ ...details, id, orderId, status, method: sel!.method, locationId: sel!.locationId, date: sel!.date, slotStart: sel!.slot.split("-")[0], slotEnd: sel!.slot.split("-")[1], rescheduleCount: 0, }); if (!sel) { const b: Booking = { ...details, id, orderId, status: "needs_review", method: "pickup", locationId: "", date: "" }; this.bookings.set(id, b); return { ok: false, booking: b, code: "NEEDS_REVIEW", message: "Order has no scheduling data" }; } const live = hold && hold.expiresAt > now; if (!live) { const full = this.fits(sel, caps, now, token ?? undefined); if (full) { const b = base("needs_review"); this.bookings.set(id, b); if (token) this.holds.delete(token); this.log(now, "system", id, "flagged", undefined, { reason: full }); return { ok: false, booking: b, code: "NEEDS_REVIEW", message: "Hold expired and the slot filled up" }; } } const b = base("confirmed"); this.bookings.set(id, b); if (token) this.holds.delete(token); this.log(now, "system", id, "confirmed"); return { ok: true, booking: b, duplicate: false, reReserved: !live }; } /** Atomic move: validates the new slot, then releases old + takes new together. */ reschedule( bookingId: string, sel: Selection, caps: Caps, now: number, actor: string, ): { ok: true; booking: Booking; freed: Selection } | { ok: false; message: string } { const b = this.bookings.get(bookingId); if (!b || b.status !== "confirmed") return { ok: false, message: "Booking can't be rescheduled" }; const full = this.fits(sel, caps, now, undefined, bookingId); if (full) return { ok: false, message: full === "DAY_FULL" ? "That day is fully booked" : "That slot is full" }; const before = { ...b }; const freed: Selection = { locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` }; const [s, e] = sel.slot.split("-"); Object.assign(b, { locationId: sel.locationId, method: sel.method, date: sel.date, slotStart: s, slotEnd: e, rescheduleCount: (b.rescheduleCount ?? 0) + 1, }); this.log(now, actor, bookingId, "rescheduled", pick(before), pick(b)); return { ok: true, booking: b, freed }; } cancel(orderId: string, now: number, releaseCapacity = true): Booking | null { const b = [...this.bookings.values()].find((x) => x.orderId === orderId); if (!b || b.status === "cancelled") return b ?? null; const before = { ...b }; b.status = releaseCapacity ? "cancelled" : b.status; this.log(now, "system", b.id, "cancelled", pick(before), pick(b)); return b; } joinWaitlist(sel: Selection, contact: WaitlistEntry["contact"], now: number): WaitlistEntry { const e: WaitlistEntry = { ...sel, id: `wl_${++this.seq}`, contact, createdAt: now }; this.waitlist.push(e); return e; } /** Who to notify when capacity frees up for a selection (FIFO, one at a time). */ nextWaitlisted(sel: Selection): WaitlistEntry | null { return ( this.waitlist.find( (w) => !w.notifiedAt && w.locationId === sel.locationId && w.method === sel.method && w.date === sel.date && (w.slot === sel.slot || w.slot === "*"), ) ?? null ); } private log(at: number, actor: string, bookingId: string, action: string, before?: unknown, after?: unknown) { this.audit.push({ at, actor, bookingId, action, before, after }); } } function pick(b: Booking) { return { locationId: b.locationId, method: b.method, date: b.date, slotStart: b.slotStart, slotEnd: b.slotEnd, status: b.status }; }