NorthBlue · booking engine v2

Five dials. One hold.
Any product.

Bike rentals, hop-on buses, timed museums, escape rooms, free welcome vouchers, driver-gated shuttles, FareHarbor-connected attractions — none of them get their own code path. Every product is five dial settings; every booking is the same hold. Tap through nine real products:

When alwaysrecurringnonesupplier-owned
How many unlimitedseatspoolsupplier-owned
How long pointpassrental
Who confirms instantthresholdapproval
Inventory lives nativeconnectedexternal link

the one primitive

Every booking is a hold with a lifespan.

Capacity is never a stored counter that drifts. A booking holds units for a time window; free capacity is simply what's not overlapped right now. Expire, cancel, or return early — the space comes back on its own. Free products ride the same rail — they just skip the charge.

HELD Seats / bikes / spots counted against capacity. Has an expiry — survives only if something confirms it.
pay · $0 skip · approve · threshold met · supplier OK
CONFIRMED Charged — or free, or approved. The window is owned until it ends. QR / voucher code issued here.
window ends
COMPLETED Done. History, analytics, reviews.
RELEASED Hold expired — payment abandoned mid-checkout, or supplier never confirmed. ↩ capacity returns automatically
CALLED OFF Threshold trip didn't reach its minimum by the cutoff. Nobody was ever charged. ↩ capacity returns automatically
CANCELLED Guest or seller cancels; refund follows the policy. ↩ capacity returns automatically

This one machine closes today's oversell bug. The hold is taken before payment, atomically — two guests racing for the last seat can't both win, and an abandoned checkout gives the seat back by itself.

the layer beneath a product

One card, many things — split two ways, never one.

A product often bundles several bookables under one card. They divide two ways, and flattening them into a single option list is the classic engine mistake: it is why child vs adult and classic vs electric look alike yet must be modelled as opposites. One is a price on shared stock; the other is a pool of its own.

VARIANT A different thing to keep. Its own stock. Booking one never touches another.
🚲Classicpool of 6
Electricpool of 10
  • Owns inventory — its own units or seats.
  • May override any dial — its own hours, capacity, hold length, even price base.
  • A hold belongs to exactly one variant. Classic bookings never shrink electric.
classic vs electric bike · single vs double kayak · standard vs suite
FARE A different price for the same thing. No stock of its own. It prices a hold.
RATEadult · child · senior+1 head, same pool
TERM2 hours · half-day · full-daysame unit, longer hold
  • Holds no inventory. It is a lens, not a pool.
  • Rate adds a head that counts against the variant's capacity.
  • Term changes how long the hold runs — same unit, later return.
adult vs child · 2-hour vs full-day · resident vs visitor
Does booking it shrink the other's stock?
No → variant. Give it a pool.Classic and electric are different bikes; selling one leaves the other untouched. Yes → fare. Price the hold.An adult and a child take the same one seat; a full-day and a 2-hour take the same one bike.
1Pick a variantits calendar, its stock
2Pick a termsets the hold's length
3Pick ratesheads & price
One holdon that variant's pool

No new primitive. A variant is the five dials at a finer grain — its own stock, its own optional overrides for when, how many, and how long. A fare is a lens on the hold — it changes price, headcount, or length, never inventory. The hold machine already counts both: free capacity is still “units not overlapped”, now asked per variant.

the case fareharbor can't do

“I'll drive if enough people book.”

Your airport shuttle. On FareHarbor this is phone calls and guesswork. Here it's one dial: who confirms → threshold. Bookings pool up; each card is authorized, not charged, and nobody pays unless the trip actually runs. If it never fills, the hold is released and the guest is never charged at all — see payments §06.

Mon Ana books 2 seats
2 / 6 to run Card authorized. Hold placed. charged: $0
Tue Concierge suggests it to two more guests
4 / 6 to run Guest page shows “2 more to confirm”. charged: $0
Thu · 6:00 PM 6th seat books — threshold met
6 / 6 · CONFIRMED All holds → confirmed. Everyone notified. charged: all riders
Fri · 7:00 AM Van departs full
boarding 6 Driver got the manifest last night. trip runs
The other ending Cutoff hits at 4 / 6
seller decides Run anyway — or call off: $0 charged, guests notified, concierge offers alternatives. no-risk for guests
Why drivers say yes Zero empty runs. The trip only exists once it's worth driving — and the hotel's concierge + guest pages actively pool demand toward the minimum. That's distribution FareHarbor doesn't have: a building full of guests going to the same airport.
Why guests say yes Booking is risk-free: card saved, charged only on confirmation. Live progress (“4 of 6”) creates the nudge that fills the last seats — the same mechanic that makes group deals work.
what the guest sees
Airport shuttle · Fri 7:00 AM$18 / seat
4 of 6 seats spoken for — confirms by Thu 6:00 PM
Reserve a seat — pay only if it runs
Card saved now · charged on confirmation · free cancel until cutoff
sellers with their own systems

Own inventory, or theirs — same four verbs.

Some sellers live in FareHarbor, Ventrata, Bókun — the systems behind hop-on-hop-off fleets and big-city observation decks. Fine: the checkout, concierge and calendar only ever speak four verbs to a booking core. Behind it sit two engines — ours, and a connector speaking OCTO, the industry's open connectivity standard. A seller flips one dial: inventory lives → connected.

Guest checkout AI concierge Hotel dashboard Seller calendar
Booking core — one contract
availability()hold()confirm()cancel()
↓     ↓

Native engine

Rules + exceptions + holds in our Postgres. Powers everything on the five dials — pools, rentals, thresholds, passes, free vouchers.
availability_rulesexceptionsresource_unitsholds

Connected engine

A thin connector; the supplier's system stays the source of truth. OCTO's flow maps onto ours one-to-one:
availability()OCTO availability / calendar
hold()OCTO reserve → ON_HOLD + expiry
confirm()OCTO confirm
cancel()OCTO cancel + webhooks
Ventrata · OCTO-nativeFareHarbor · via APIBókunanything OCTO

Fast in practice: month calendars come from a short-TTL cache; picking a time does a live check; paying places the supplier hold. Supplier webhooks + a nightly re-sync keep the mirror honest — and if their API is down, we show the cache, refuse new holds, and never double-charge.

selling what you don't issue

Who issues the ticket is a different question from who holds the stock.

On most products we are the seller and the booking is the ticket. On others — Alcatraz is ours — we are a reseller: we hold a real allotment and take the money, but the operator issues the boarding voucher and emails it. Promising a QR we never mint is how a guest ends up at the pier with nothing to show.

self-issued We mint it. The confirmation page is the ticket. Bike rental, the hotel's own events. redemption: ticket
  • Shown instantly on confirm.
  • Guest needs nothing else.
supplier-issued They mint it; we resell it. We confirm and charge; the operator emails the real voucher. Ours is a receipt. redemption: email · voucher_eta_minutes
  • No QR at checkout — and we must not imply one.
  • The wait is a number, not "soon": the product carries its own ETA.
  • If it never lands, the front desk chases it with the confirmation code.
nothing to show A name on a list. Turn up and say who you are. Lobby tastings, walk-ins. redemption: none
  • No ticket, no voucher, no chase.
1Guest paysThe hold confirms exactly as any other product — same one machine.
2Receipt, not ticketThe confirmation says the voucher is coming, names the inbox, and gives the window.
3Operator emailsTheir system issues the boarding voucher to the guest directly.
4Overdue → chasedPast the window, the confirmation code is what the desk quotes to the operator.

The dials don't change. Fulfilment is a property of the product, not a sixth dial: the same hold, the same capacity, the same checkout. All that changes is what the guest is told to expect once the money is taken — which is the one thing a resold product must get right.

the other thing in the guest app

A paid event is a product with a date. A free one isn’t a product at all.

Next to the catalogue sits This Week — the lobby tasting, the morning ride, the rooftop party. Two of those you turn up to; one you buy a ticket for. The temptation is to give events their own price, capacity and checkout. That is a second booking engine hiding inside the events table, and it will drift from the first one within a month.

free event Nothing to sell. A time, a place, a description. No stock, no hold, no checkout — the guest reads it and turns up. guest_events · experience_id: null
paid event A product whose calendar is one or two dates. The seller names a price and a ticket count; behind it we create an ordinary product and point the event at it. Same hold, same checkout, same refund. { when: none + open(date) · cap: seats(n) · dur: point · conf: instant }

This is the NYE party, wearing a different hat. The dials already describe a one-night ticketed thing — a closed schedule with a single open date, a seat count, instant confirm. A paid event is that product, created from a two-field form instead of the full product editor. Events never learn what a hold is.

Three places an event can come from.

The guest sees one list. Underneath, the row records who owns it — which decides who may edit it, and whose guests it reaches.

The hotel’s own Lobby tastings, movie nights. Only its own guests see it. seller_id: null
NorthBlue citywide City-wide happenings shown across every hotel in the city. scope: citywide
A seller, at a partner The seller writes it and picks which partner hotel shows it — any hotel they have not locked. seller_id + hotel_id

A seller event is still a hotel event. It is an ordinary hotel-scoped row that also carries a seller id, so This Week, the event page and the hotel’s own feed render it with no special case. Ownership is the only new idea: the seller’s writes are scoped to their own rows, and the hotel’s writes skip anything a seller owns.

worst-case gallery

Break it. It bends back.

Each nasty case resolves through one mechanism — the hold, the pool, the exceptions layer, the threshold, or the connector. No special-case code.

Two guests race for the last seatHold is atomic and taken before payment. One wins; the other instantly sees “just sold out”.hold
Guest abandons mid-paymentThe hold expires on its own; the seat is back on sale minutes later. No cron, no manual fix.hold
Bike comes back 3 hours earlyStaff taps “returned” → window_end = now() → that bike is instantly rentable again.pool
Two bikes break in storageFlip units to broken; the effective pool shrinks; existing rentals untouched, future ones see fewer bikes.pool
The return guy leaves earlyOne hours exception for today (“returns until 4 PM”) — new rentals auto-shorten to fit the window.exception
Museum closed for a holidayOne closed exception row. The computed calendar goes dark that day. No hunting through 313 slots.exception
Ferry cancelled for weatherSeller cancels the departure: confirmed holds → cancelled, auto-refund, guests notified, concierge suggests alternatives.hold
Shuttle stuck at 4 of 6Cutoff fires: seller chooses run-anyway or call-off. Called off = $0 charged, all seats released.threshold
Free-drink voucher farmingFree still means a hold — with a per-guest limit (one per stay). The 11th voucher politely refuses.hold
Escape room no-showThe room's hold simply ends at window-end and frees itself; the no-show policy decides the refund, not the inventory.pool
FareHarbor API times out mid-checkoutCalendar serves from cache; new holds refuse gracefully (“try again shortly”); confirm is idempotent — never a double charge.connector
A paid event is cancelledIt is a product, so the ordinary path applies: cancel the departure, confirmed holds flip to cancelled, refunds follow the policy, guests are notified. Nothing event-shaped had to be written.product
A seller event outlives the partnershipThe hotel picker only offers hotels the seller has not locked, and the server re-checks on write — so an event cannot be aimed at a hotel the seller has switched off.ownership
A seller wants out of one hotel, not all of themOne switch on the Partner hotels page. Locked = their products, events, checkout links and the concierge’s catalog all skip that hotel; nothing is deleted, so unlocking restores the shelf. Every booking route refuses a locked pair too, so a saved link cannot get round it.ownership
Resold voucher never arrivesThe confirmation already told the guest the window and named the inbox. Past it, the desk quotes the confirmation code to the operator — the booking exists on both sides, so there is something to chase.fulfilment
Supplier cancels via their own dashboardWebhook (or nightly re-sync) flips our mirror booking, triggers refund + guest notification.connector
Bike returned late, next guest waitingOverdue hold flags at window-end; staff alerted; next overlapping booking gets a proactive “running late” notice.pool
Seller edits capacity below sold countDerived capacity can't dip under confirmed holds — the UI shows the real floor instead of silently overselling.hold
NYE party on a “closed” scheduleSchedule none + one open exception on Dec 31. The calendar is dark all year except the night that matters.exception
the honest comparison

FareHarbor is the standard. This is the edge.

FareHarbor's moat is distribution breadth. Ours is the hotel itself — plus a capacity model they don't have. We don't out-FareHarbor FareHarbor; we plug into it and win the cases it fumbles.

FareHarbor & co.This engine
Min-people trips
Per-booking minimums only; pooled “runs if 6 book” = phone calls and manual holds.
Native threshold dial: pooled bookings, authorize-then-capture-on-confirm, auto call-off. Zero risk both sides.
Rentals + tours + passes + freebies
Separate product silos with separate setup models.
One model — a pool rental, a timed tour and a $0 voucher differ by dial positions.
Demand source
You bring your own traffic.
A hotel full of guests + an AI concierge that actively fills under-threshold departures.
Connectivity
Proprietary APIs first.
OCTO-first: their systems plug into us through one connector — sellers keep their tools.
rollout

Three additive phases. Stripe flow untouched.

1

Rules replace rows

Add availability_rules + availability_exceptions; compute calendars from rules. The museum's 313 rows become one rule + holidays. Old tables stay live in parallel.

reversible
2

Holds become truth

Bookings gain window + qty + expiry; the atomic hold() replaces the floor-at-zero decrement. Oversell closed; capacity self-healing; $0 products skip capture.

the core fix
3

Pools, thresholds, OCTO

resource_units makes bikes and escape rooms bookable; the threshold dial launches the shuttle; the OCTO connector onboards FareHarbor/Ventrata sellers behind the same four verbs.

unlocks the hard cases
DB delta new availability_rules new availability_exceptions new resource_units new departures new connections alter bookings + window, qty, hold_expires_at, provider_ref

Departure rows materialize on demand — an empty calendar stays purely computed; a row exists only once someone books toward it. The database stores demand, never emptiness.