@classytic/reservation
v0.1.1
Published
Interval-booking spine — reserve bookable resources over [start, end): hotel rooms, vacation rentals, courts, desks, appointments. Race-proof variable-start exclusivity (per-unit allocations), pooled capacity, folio billing, FSM lifecycle with sweepers. I
Readme
@classytic/reservation
The interval-booking spine — reserve bookable resources over
[start, end): hotel rooms, vacation rentals, courts, desks,
appointments. Anything that is "an owned resource, held for an interval,
billed on a folio."
Owns the reservation DOMAIN. Money composes via bridges: the financial
record is an @classytic/order booking order, folio settlement posts to
your ledger — this package never imports either.
The 90-second version
import { createReservation } from '@classytic/reservation';
const reservation = await createReservation({
connection,
tenant: { fieldType: 'objectId' }, // property/branch scoping
defaultTimezone: 'Asia/Dhaka', // night math is PROPERTY-LOCAL
bridges: { ledger }, // folio settlement seam
});
const { reservation: reservations, resource, folio } = reservation.repositories;
// Rooms are units; their TYPE (Deluxe: photos, amenities) is a catalog Product.
await resource.createResource({ code: 'R-402', productRef: 'prod-deluxe' }, ctx);
// Hold → confirm → check-in → check-out.
const r = await reservations.hold(
{
lines: [{
resourceCode: 'R-402',
period: { start: checkInInstant, end: checkOutInstant },
granularity: 'night',
rate: { amount: 500_000, currency: 'BDT' }, // ৳5,000/night, paisa
}],
guestName: 'Ayesha Rahman',
orderNumber: 'ORD-1042', // the financial order (optional — walk-ins have none)
},
ctx,
);
await reservations.confirm(String(r._id), ctx); // ← allocations written HERE (race-proof)
await reservations.checkIn(String(r._id), ctx);
await reservations.checkOut(String(r._id), ctx); // auto-posts room nights to the folio
// The folio collected room nights + extras; settle posts ONE ledger entry.
await folio.postLine(folioId, { kind: 'fnb', amount: 120_000 }, ctx);
await folio.settle(folioId, ctx);Why this exists — the race order's index cannot close
Order's booking_slot_unique_active unique index closes grid-aligned
races only (same startsAt). Two concurrent variable-length stays
(1–5 Jan vs 3–7 Jan) hit different index keys — both commit.
confirm() expands each stay into one Allocation row per occupied
unit (property-local night, or slot start) inside a transaction. ANY
two overlapping stays collide on at least one row; the loser gets E11000
→ the same typed SlotConflictError the pre-check throws → its whole
transaction (all rooms of a group, the FSM claim, the folio) rolls back.
Terminal transitions flip rows to released in the same transaction —
the record survives (no TTL-deletes), the unique slot frees.
Pooled resources (dorm beds, lanes: capacityMode: 'pooled') use an
atomic capacity-bounded counter per unit (incrementIfBelow) instead.
The order seam — one line
orderConfig.bridges.booking = reservation.bookingBridge();
// exactly order's BookingBridge port: { checkSlotAvailability, listBookedSlots }
// incl. opts.excludeOrderNumber so an order re-validating doesn't see itself.Event contract (idempotent both directions — redelivery-safe):
| Direction | Event | Handler |
|---|---|---|
| order → reservation | booking order confirmed | reservations.confirm(id) (no-op when already confirmed) |
| order → reservation | order canceled/refunded | reservations.cancel(id) (no-op when already canceled) |
| reservation → order | reservation:reservation.checked_out | host completes the order |
| reservation → order | …expired / …no_show | host cancels/adjusts per policy |
Domain model
| Model | Role |
|---|---|
| Resource | the physical unit (room 402) — productRef → catalog type; holds via primitives /hold; conditionStatus labels are host policy (housekeeping vs inspection) |
| Reservation | header + lines (one reservation, many rooms), FSM: pending → confirmed → checked_in → checked_out (+ no_show, canceled, expired) |
| Allocation | one row per (resource × unit) — THE exclusivity guard |
| PoolCounter | capacity-bounded occupancy for pooled resources |
| Folio | running stay account: atomic $push+$inc postings, settle → ledger bridge |
Nights are property-local civil dates (civilDateOf, DST-correct);
money is integer minor units everywhere; rates are snapshotted onto
lines at hold — a price change never rewrites a stay.
Operational verbs
hold— pending +expiresAt(default 30 min). OTA-idempotent: a redelivered(source, externalRef)returns the existing reservation.confirm— transactional allocations + folio open. Idempotent by state.checkIn/checkOut— checkout releases units + auto-postsroom_nightlines in one transaction.cancel/noShow— release units, void the open folio.expirePending(now)— the sweeper (host wires the cron; never TTL).findAvailable(range, {productRef})— "which rooms are free 3–7 Jan?"folio.settle— ledger post with deterministic idempotency key (folio:<id>:settle), crash-retry convergent: retry after a crash yields exactly one JE (the bridge MUST dedupe on the key).
Events
reservation:reservation.{held,confirmed,checked_in,checked_out,no_show,canceled,expired},
reservation:resource.{blocked,unblocked,condition_changed},
reservation:folio.{opened,line_posted,settled,voided}.
§P8.1-canonical dispatch with a post-commit queue: multi-write verbs
queue events inside their transaction and flush after commit (no ghost
events on rollback); engine.withTransaction(ctx, fn) gives hosts the
same discipline; session + outbox = durable-relay-only.
What this package does NOT do
- Money: payment/refund → revenue; the priced order → order; invoices → invoice; cancellation-fee math → catalog's refund policy on the order side.
- Fixed-slot grid UIs work WITHOUT this package (catalog
computeSlotAvailability+ order's default bridge) — reach for reservation when you need the operational lifecycle, variable-length stays, pooled capacity, or race-proof variable-start exclusivity. - Rate calendars:
bridges.catalog.getRate(productRef, range)is deliberately range-shaped so catalog can grow a nightly/seasonal mode without this port changing.
