Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
# `booking`
Universal booking engine with availability, conflict detection, and pricing.
<!-- begin:auto-docs -->
## Packages in `booking`
1 package.
| Package | Description |
|---|---|
| [`@suaveplan/booking`](./booking/README.md) | Universal, JSON-configurable booking engine with pluggable strategies for availability resolution, conflict detection, pricing calculation, and rule evaluation… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Universal booking engine with availability, conflict detection, and pricing.

View File

@@ -0,0 +1,220 @@
# @suaveplan/booking - Universal JSON-configurable booking engine: pluggable availability resolution, conflict detection, pricing pipeline, rule evaluation, configurable status state machine, typed event bus, in-package data-transform Hooks pipeline, six domain presets (seats, accommodation, appointments, resources, activities, transport), Zod validation schemas, and typed BookingError hierarchy with stable BOOKING.* code registry.
Universal, JSON-configurable booking engine with pluggable strategies for availability resolution, conflict detection, pricing calculation, and rule evaluation. Ships six built-in domain presets (seats, accommodation, appointments, resources, activities, transport), a configurable status state machine, a typed lifecycle event bus, an in-package `Hooks<TMap>` data-transform pipeline, Zod validation schemas, and a typed `BookingError` hierarchy.
<!-- begin:auto-docs -->
## Documentation tree
- [resolver](./src/availability/resolver/resolver.md) — Pluggable availability-resolver registry. The booking engine asks "is this window available?" by delegating to a strate…
- [resolver](./src/availability/resolver/resolver.md) — Pluggable availability-resolver registry. The booking engine asks "is this window available?" by delegating to a strate…
- [strategies](./src/availability/strategies/strategies.md) — Three built-in `AvailabilityStrategyFn` implementations covering the three most common booking semantics: `exactMatchSt…
- [strategies](./src/availability/strategies/strategies.md) — Three built-in `AvailabilityStrategyFn` implementations covering the three most common booking semantics: `exactMatchSt…
- [detector](./src/conflict/detector/detector.md) — Provides the pluggable conflict-detection registry used by the booking engine to decide whether a proposed window colli…
- [detector](./src/conflict/detector/detector.md) — Provides the pluggable conflict-detection registry used by the booking engine to decide whether a proposed window colli…
- [exact-match](./src/conflict/exact-match/exact-match.md) — Conflict-detection strategy for seat-style bookings where two reservations conflict only when their start AND end times…
- [exact-match](./src/conflict/exact-match/exact-match.md) — Conflict-detection strategy for seat-style bookings where two reservations conflict only when their start AND end times…
- [provider-overlap](./src/conflict/provider-overlap/provider-overlap.md) — Conflict-detection strategy for appointment-style bookings where the constraint is "this provider's calendar." Used for…
- [provider-overlap](./src/conflict/provider-overlap/provider-overlap.md) — Conflict-detection strategy for appointment-style bookings where the constraint is "this provider's calendar." Used for…
- [range-overlap](./src/conflict/range-overlap/range-overlap.md) — Conflict-detection strategy for accommodation-style bookings where any temporal overlap counts as a conflict. Models ho…
- [range-overlap](./src/conflict/range-overlap/range-overlap.md) — Conflict-detection strategy for accommodation-style bookings where any temporal overlap counts as a conflict. Models ho…
- [slot-capacity](./src/conflict/slot-capacity/slot-capacity.md) — Conflict-detection strategy for group bookings — meeting rooms, tour groups, activity classes, anything where multiple…
- [slot-capacity](./src/conflict/slot-capacity/slot-capacity.md) — Conflict-detection strategy for group bookings — meeting rooms, tour groups, activity classes, anything where multiple…
- [engine](./src/core/engine/engine.md) — `BookingEngine` is the booking system's pipeline orchestrator. It composes the registry of bookables, the persistent ma…
- [engine](./src/core/engine/engine.md) — `BookingEngine` is the booking system's pipeline orchestrator. It composes the registry of bookables, the persistent ma…
- [event-bus](./src/core/event-bus/event-bus.md) — Typed publish/subscribe event bus for booking lifecycle events. The engine emits the 15 standard `BookingEventType` eve…
- [event-bus](./src/core/event-bus/event-bus.md) — Typed publish/subscribe event bus for booking lifecycle events. The engine emits the 15 standard `BookingEventType` eve…
- [state-machine](./src/core/state-machine/state-machine.md) — Configurable booking-status state machine. Consumers pass an array of `StatusTransition` rows (from `BookingSystemConfi…
- [state-machine](./src/core/state-machine/state-machine.md) — Configurable booking-status state machine. Consumers pass an array of `StatusTransition` rows (from `BookingSystemConfi…
- [domains](./src/domains/shared.md) — Provides `createBaseConfig(tenantId, tenantName)` — the canonical default `BookingSystemConfig` factory that every doma…
- [accommodation](./src/domains/accommodation/accommodation.md) — Factory for the accommodation booking domain — hotels, vacation rentals, hostels. Each bookable represents one bookable…
- [accommodation](./src/domains/accommodation/accommodation.md) — Factory for the accommodation booking domain — hotels, vacation rentals, hostels. Each bookable represents one bookable…
- [activities](./src/domains/activities/activities.md) — Factory for the activity/tour booking domain — group tours, classes, workshops, group events. Each bookable represents…
- [activities](./src/domains/activities/activities.md) — Factory for the activity/tour booking domain — group tours, classes, workshops, group events. Each bookable represents…
- [appointments](./src/domains/appointments/appointments.md) — Factory for the appointment booking domain — medical, dental, salon, consulting. Each bookable represents an appointmen…
- [appointments](./src/domains/appointments/appointments.md) — Factory for the appointment booking domain — medical, dental, salon, consulting. Each bookable represents an appointmen…
- [resources](./src/domains/resources/resources.md) — Factory for the resource booking domain — meeting rooms, equipment, shared vehicles. Each bookable represents a resourc…
- [resources](./src/domains/resources/resources.md) — Factory for the resource booking domain — meeting rooms, equipment, shared vehicles. Each bookable represents a resourc…
- [seats](./src/domains/seats/seats.md) — Factory for the seat-based booking domain — stadiums, theatres, cinemas, flights. Each bookable represents one physical…
- [seats](./src/domains/seats/seats.md) — Factory for the seat-based booking domain — stadiums, theatres, cinemas, flights. Each bookable represents one physical…
- [transport](./src/domains/transport/transport.md) — Factory for the transport booking domain — flights, trains, buses. Each bookable represents a single transport-seat on…
- [transport](./src/domains/transport/transport.md) — Factory for the transport booking domain — flights, trains, buses. Each bookable represents a single transport-seat on…
- [shared](./src/domains/shared.md) — Provides `createBaseConfig(tenantId, tenantName)` — the canonical default `BookingSystemConfig` factory that every doma…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `BookingError` class for `@suaveplan/booking`. Every failure path the…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `BookingError` class for `@suaveplan/booking`. Every failure path the…
- [hooks](./src/hooks/hooks.md) — In-package data-transform pipeline used by `BookingEngine` to give consumers a hook into the create-booking and cancel-…
- [hooks](./src/hooks/hooks.md) — In-package data-transform pipeline used by `BookingEngine` to give consumers a hook into the create-booking and cancel-…
- [calculator](./src/pricing/calculator/calculator.md) — Implements the booking engine's price-calculation pipeline. `calculatePrice` is the canonical entry point: it runs base…
- [calculator](./src/pricing/calculator/calculator.md) — Implements the booking engine's price-calculation pipeline. `calculatePrice` is the canonical entry point: it runs base…
- [dynamic](./src/pricing/dynamic/dynamic.md) — Implements dynamic-pricing rule evaluation. Three trigger types are supported — `occupancy_threshold`, `advance_days`,…
- [dynamic](./src/pricing/dynamic/dynamic.md) — Implements dynamic-pricing rule evaluation. Three trigger types are supported — `occupancy_threshold`, `advance_days`,…
- [seasonal](./src/pricing/seasonal/seasonal.md) — Applies seasonal-rate multipliers to a base price based on month/day windows. Supports wrap-around-year intervals (e.g.…
- [seasonal](./src/pricing/seasonal/seasonal.md) — Applies seasonal-rate multipliers to a base price based on month/day windows. Supports wrap-around-year intervals (e.g.…
- [conditions](./src/rules/conditions/conditions.md) — Pre-built `RuleCondition` factories for the most common booking constraints — minimum/maximum duration, advance-booking…
- [conditions](./src/rules/conditions/conditions.md) — Pre-built `RuleCondition` factories for the most common booking constraints — minimum/maximum duration, advance-booking…
- [evaluator](./src/rules/evaluator/evaluator.md) — The rules-engine evaluator walks a list of `BookingRule[]` against an `EvaluationContext` (booking + bookable + window…
- [evaluator](./src/rules/evaluator/evaluator.md) — The rules-engine evaluator walks a list of `BookingRule[]` against an `EvaluationContext` (booking + bookable + window…
- [booking.schema](./src/schemas/booking.schema.md) — Zod validation schemas covering every entity at the booking engine's input boundary — `Bookable`, `Booking`, `BookingRe…
- [config.schema](./src/schemas/config.schema.md) — Validates the full `BookingSystemConfig` JSON tree at runtime. The schema mirrors the `config.types.ts` shape and is th…
- [recurrence](./src/temporal/recurrence/recurrence.md) — Expands `RecurrenceRule` definitions into concrete `TimeWindow[]` occurrences across a date range. Supports daily, week…
- [recurrence](./src/temporal/recurrence/recurrence.md) — Expands `RecurrenceRule` definitions into concrete `TimeWindow[]` occurrences across a date range. Supports daily, week…
- [slots](./src/temporal/slots/slots.md) — Generates and manipulates `TimeSlot` lists. `generateTimeSlots` produces fixed-interval slots covering a single `TimeWi…
- [slots](./src/temporal/slots/slots.md) — Generates and manipulates `TimeSlot` lists. `generateTimeSlots` produces fixed-interval slots covering a single `TimeWi…
- [windows](./src/temporal/windows/windows.md) — Defines pure functions for reasoning about `TimeWindow` intervals — overlap, merge, split, containment, adjacency, vali…
- [windows](./src/temporal/windows/windows.md) — Defines pure functions for reasoning about `TimeWindow` intervals — overlap, merge, split, containment, adjacency, vali…
- [utils](./src/utils/utils.md) — Shared, dependency-free helpers used across `@suaveplan/booking`. Every export here is pure, side-effect-free, and runs…
- [utils](./src/utils/utils.md) — Shared, dependency-free helpers used across `@suaveplan/booking`. Every export here is pure, side-effect-free, and runs…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/booking
```
## Quick start
```ts
import { createBookingEngine, createSeatConfig } from "@suaveplan/booking";
const engine = createBookingEngine();
engine.configure(createSeatConfig("arena-1", "Main Arena"));
engine.registerBookable({
identifierRef: { id: "seat-A12", tenantId: "arena-1", categoryId: "vip" },
name: "Seat A12",
description: "Aisle seat",
bookableType: "seat",
status: "active",
layoutNodeRef: null,
capacityRef: "cap-A12",
priceRuleRefs: [],
timeSlotRefs: [],
attributes: {},
});
const result = engine.checkAvailability({
bookableRef: "seat-A12",
windowRef: {
startTime: "2026-06-01T19:00:00Z",
endTime: "2026-06-01T22:00:00Z",
timezone: "UTC",
},
quantity: 1,
});
console.log(result.available);
```
## Architecture
```text
Availability → Conflict detection → Pricing → Rules → State machine → Events
```
Every stage is pluggable via registries (`registerDetector`, `registerAvailabilityStrategy`) and the `Hooks<BookingHookMap>` accessor exposes pre-persistence transform points (`booking:creating`, `booking:cancelling`).
## Modules
### Foundation
| Module | Documentation |
|---|---|
| Error codes (`BOOKING_CODES`, `BookingError`) | [src/error-codes/error-codes.md](src/error-codes/error-codes.md) |
| Hooks (in-package `Hooks<TMap>` pipeline) | [src/hooks/hooks.md](src/hooks/hooks.md) |
| Utils (`parseDurationMs`, …) | [src/utils/utils.md](src/utils/utils.md) |
### Types
| Module | Documentation |
|---|---|
| Bookable | [src/types/bookable.types.md](src/types/bookable.types.md) |
| Booking | [src/types/booking.types.md](src/types/booking.types.md) |
| Booking hooks | [src/types/booking-hooks.types.md](src/types/booking-hooks.types.md) |
| Capacity | [src/types/capacity.types.md](src/types/capacity.types.md) |
| Config | [src/types/config.types.md](src/types/config.types.md) |
| Events | [src/types/events.types.md](src/types/events.types.md) |
| Layout | [src/types/layout.types.md](src/types/layout.types.md) |
| Pricing | [src/types/pricing.types.md](src/types/pricing.types.md) |
| Rules | [src/types/rules.types.md](src/types/rules.types.md) |
| Temporal | [src/types/temporal.types.md](src/types/temporal.types.md) |
### Temporal
| Module | Documentation |
|---|---|
| Windows | [src/temporal/windows/windows.md](src/temporal/windows/windows.md) |
| Recurrence | [src/temporal/recurrence/recurrence.md](src/temporal/recurrence/recurrence.md) |
| Slots | [src/temporal/slots/slots.md](src/temporal/slots/slots.md) |
### Conflict detection
| Module | Documentation |
|---|---|
| Detector registry | [src/conflict/detector/detector.md](src/conflict/detector/detector.md) |
| Exact match | [src/conflict/exact-match/exact-match.md](src/conflict/exact-match/exact-match.md) |
| Range overlap | [src/conflict/range-overlap/range-overlap.md](src/conflict/range-overlap/range-overlap.md) |
| Slot capacity | [src/conflict/slot-capacity/slot-capacity.md](src/conflict/slot-capacity/slot-capacity.md) |
| Provider overlap | [src/conflict/provider-overlap/provider-overlap.md](src/conflict/provider-overlap/provider-overlap.md) |
### Pricing
| Module | Documentation |
|---|---|
| Calculator | [src/pricing/calculator/calculator.md](src/pricing/calculator/calculator.md) |
| Seasonal | [src/pricing/seasonal/seasonal.md](src/pricing/seasonal/seasonal.md) |
| Dynamic | [src/pricing/dynamic/dynamic.md](src/pricing/dynamic/dynamic.md) |
### Rules
| Module | Documentation |
|---|---|
| Evaluator | [src/rules/evaluator/evaluator.md](src/rules/evaluator/evaluator.md) |
| Conditions | [src/rules/conditions/conditions.md](src/rules/conditions/conditions.md) |
### Availability
| Module | Documentation |
|---|---|
| Resolver registry | [src/availability/resolver/resolver.md](src/availability/resolver/resolver.md) |
| Built-in strategies | [src/availability/strategies/strategies.md](src/availability/strategies/strategies.md) |
### Domains
| Module | Documentation |
|---|---|
| Shared base | [src/domains/shared.md](src/domains/shared.md) |
| Seats | [src/domains/seats/seats.md](src/domains/seats/seats.md) |
| Accommodation | [src/domains/accommodation/accommodation.md](src/domains/accommodation/accommodation.md) |
| Appointments | [src/domains/appointments/appointments.md](src/domains/appointments/appointments.md) |
| Resources | [src/domains/resources/resources.md](src/domains/resources/resources.md) |
| Activities | [src/domains/activities/activities.md](src/domains/activities/activities.md) |
| Transport | [src/domains/transport/transport.md](src/domains/transport/transport.md) |
### Schemas
| Module | Documentation |
|---|---|
| Booking schema | [src/schemas/booking.schema.md](src/schemas/booking.schema.md) |
| Config schema | [src/schemas/config.schema.md](src/schemas/config.schema.md) |
### Core
| Module | Documentation |
|---|---|
| Booking engine | [src/core/engine/engine.md](src/core/engine/engine.md) |
| State machine | [src/core/state-machine/state-machine.md](src/core/state-machine/state-machine.md) |
| Event bus | [src/core/event-bus/event-bus.md](src/core/event-bus/event-bus.md) |
## Tree-shaking
Every feature module ships at its own subpath export:
```ts
import { windowsOverlap } from "@suaveplan/booking/temporal/windows/windows";
import { calculatePrice } from "@suaveplan/booking/pricing/calculator/calculator";
import { createSeatConfig } from "@suaveplan/booking/domains/seats/seats";
```
## License
MIT — © Suaveplan Team

View File

@@ -0,0 +1,78 @@
# resolver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pluggable availability-resolver registry. The booking engine asks "is this window available?" by delegating to a strategy registered under a domain-specific id (e.g. `"capacity"`, `"exact"`, `"range"`). The registry is module-level; consumers seed it with built-ins from `src/availability/strategies/strategies.ts` and can add custom strategies for domains the package doesn't ship out of the box. Unknown strategy ids raise `BookingError({ code: BOOKING.UNKNOWN_AVAILABILITY_STRATEGY, strategy })` so missing registrations fail loudly.
## Features
- `registerAvailabilityStrategy(id, fn)` — registry insert.
- `resolveAvailability(id, bookable, query, existing)` — dispatch.
- `getRegisteredStrategies()` — snapshot of registry keys.
- `clearStrategyRegistry()` — reset (mostly for tests).
- Generic over `TMeta` so domain-typed metadata flows through end-to-end.
## Usage: Basic
```ts
import {
capacityStrategy, registerAvailabilityStrategy, resolveAvailability,
} from "@suaveplan/booking";
registerAvailabilityStrategy("capacity", capacityStrategy);
const result = resolveAvailability("capacity", bookable, query, existingBookings);
```
## Usage: Advanced
```ts
import {
clearStrategyRegistry, getRegisteredStrategies, registerAvailabilityStrategy,
} from "@suaveplan/booking";
import type { AvailabilityStrategyFn } from "@suaveplan/booking/availability/resolver/resolver";
clearStrategyRegistry();
const allowEveryOther: AvailabilityStrategyFn = (_bookable, query, _existing) => {
const day = new Date(query.windowRef.startTime).getUTCDay();
const available = day % 2 === 0;
return {
available, remainingCapacity: available ? 1 : 0,
conflictingBookings: [], suggestedAlternatives: [],
};
};
registerAvailabilityStrategy("every-other-day", allowEveryOther);
console.log(getRegisteredStrategies()); // ["every-other-day"]
```
## API Reference
### `registerAvailabilityStrategy(strategyId: string, strategy: AvailabilityStrategyFn): void`
Inserts the strategy into the module-level registry. Idempotent — re-registering replaces.
### `resolveAvailability<TMeta>(strategyId, bookable, query, existingBookings): AvailabilityResult`
Looks up the strategy and delegates. Throws `BookingError({ code: BOOKING.UNKNOWN_AVAILABILITY_STRATEGY, strategy })` if no strategy is registered for the id.
### `getRegisteredStrategies(): string[]`
Snapshot of currently registered ids.
### `clearStrategyRegistry(): void`
Empties the registry. Primarily for tests that need an isolated starting state.
### `AvailabilityStrategyFn<TMeta>`
`(bookable: Bookable<TMeta>, query: AvailabilityQuery, existing: Booking<TMeta>[]) => AvailabilityResult`. Strategies are pure functions — no side effects, no IO.
## Implementation Notes
- The registry is module-level (singleton) for parity with upstream. Scoped registries can land as a follow-up change.
- The generic `TMeta` is threaded through `resolveAvailability` so callers who pin a literal type (e.g. `SeatBookableMeta`) keep type safety into the strategy body.
- The runtime cast `strategy as AvailabilityStrategyFn<TMeta>` is necessary because the registry stores erased function references; the cast is contained inside the resolver so consumers never see it.

View File

@@ -0,0 +1,75 @@
# strategies
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Three built-in `AvailabilityStrategyFn` implementations covering the three most common booking semantics: `exactMatchStrategy` (seat-style; conflict iff identical window), `rangeOverlapStrategy` (accommodation-style; any overlap; honours boundary turnover), `capacityStrategy` (group-style; sums quantities up to a hard 100 cap). Consumers register the ones they need via `registerAvailabilityStrategy` and the engine dispatches on the configured strategy id. All three are pure functions with no module-level state of their own.
## Features
- `exactMatchStrategy` — seat semantics; identical-window conflict.
- `rangeOverlapStrategy` — accommodation semantics; uses `windowsOverlap(..., true)` for boundary turnover.
- `capacityStrategy` — group semantics; sums existing quantities, fixed 100 cap.
- All three generic over `TMeta` for typed metadata flow-through.
## Usage: Basic
```ts
import {
capacityStrategy, exactMatchStrategy, rangeOverlapStrategy,
registerAvailabilityStrategy,
} from "@suaveplan/booking";
registerAvailabilityStrategy("exact", exactMatchStrategy);
registerAvailabilityStrategy("range", rangeOverlapStrategy);
registerAvailabilityStrategy("capacity", capacityStrategy);
```
## Usage: Advanced
```ts
import { capacityStrategy } from "@suaveplan/booking";
import type { Bookable, Booking } from "@suaveplan/booking";
interface ClassMeta extends Record<string, unknown> { maxParticipants: number }
const yogaClass: Bookable<ClassMeta> = {
identifierRef: { id: "yoga-mon-9am", tenantId: "studio-1", categoryId: "class" },
name: "Sunrise Yoga",
description: "Vinyasa flow",
bookableType: "activity",
status: "active",
layoutNodeRef: null, capacityRef: "cap-yoga",
priceRuleRefs: [], timeSlotRefs: [],
attributes: { maxParticipants: 20 },
};
const existing: Booking<ClassMeta>[] = [/* … 18 bookings of quantity 1 … */];
const result = capacityStrategy(yogaClass, {
bookableRef: yogaClass.identifierRef.id,
windowRef: { startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T10:00:00Z", timezone: "UTC" },
quantity: 3,
}, existing);
// result.available depends on how many existing bookings overlap & their quantities.
```
## API Reference
| Strategy | Conflict definition | `remainingCapacity` |
|---|---|---|
| `exactMatchStrategy` | Same `bookableRef` AND identical start+end | 1 or 0 |
| `rangeOverlapStrategy` | Same `bookableRef` AND `windowsOverlap(..., true)` | 1 or 0 |
| `capacityStrategy` | Same `bookableRef` AND `windowsOverlap` AND summed quantity > 100 | `max(0, 100 - bookedQuantity)` |
All return `AvailabilityResult { available, remainingCapacity, conflictingBookings, suggestedAlternatives }`. `suggestedAlternatives` is always `[]` — the engine composes alternatives separately.
## Implementation Notes
- `rangeOverlapStrategy` calls `windowsOverlap(a, b, true)` — passing `allowBoundaryTurnover=true` so identical boundaries don't count as a conflict. This matches typical accommodation semantics (check-out at 11, next check-in at 14).
- `capacityStrategy`'s 100 cap is a placeholder for the upstream behaviour; production consumers should compose a custom strategy that reads `bookable.attributes.capacity` (or similar) for the true cap. A future change can thread an injectable capacity-source.
- The leading `_bookable` parameter is intentionally unused in two strategies; future strategies can use it to read `attributes.tier`, `attributes.maxOccupancy`, etc.
- All three are pure functions — registering them does not introduce hidden module state.

View File

@@ -0,0 +1,90 @@
# detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the pluggable conflict-detection registry used by the booking engine to decide whether a proposed window collides with existing ones. The registry is keyed by `ConflictDetectorConfig.mode`; built-in modes (`exact_match`, `range_overlap`, `slot_capacity`, `provider_overlap`) live in sibling directories and self-register, but consumers can add custom modes by calling `registerDetector("my-mode", fn)`. Unknown modes raise `BookingError({ code: BOOKING.UNKNOWN_CONFLICT_MODE, mode })` so missing registrations fail loudly rather than silently passing.
## Features
- `registerDetector(mode, fn)` — registry insert.
- `detectConflicts(proposed, existing, config)` — dispatches to the configured mode.
- `getRegisteredModes()` — list of currently registered mode ids.
- `clearDetectorRegistry()` — reset (mostly for tests).
- Typed errors via `BookingError`; no plain throws.
## Usage: Basic
```ts
import {
detectConflicts, detectRangeOverlap, registerDetector,
} from "@suaveplan/booking";
registerDetector("range_overlap", detectRangeOverlap);
const result = detectConflicts(
{ startTime: "2026-06-01T14:00:00Z", endTime: "2026-06-03T11:00:00Z", timezone: "UTC" },
[],
{
mode: "range_overlap", allowSameBoundaryTurnover: true,
bufferDuration: "PT0S", holdDuration: "PT10M", capacityMode: "absolute",
}
);
// result.hasConflict === false
```
## Usage: Advanced
```ts
import {
clearDetectorRegistry, detectConflicts, getRegisteredModes, registerDetector,
} from "@suaveplan/booking";
import type { ConflictDetectorFn } from "@suaveplan/booking/conflict/detector/detector";
clearDetectorRegistry();
const myMode: ConflictDetectorFn = (proposed, existing, _config) => {
const conflicts = existing.filter((w) => w.startTime === proposed.startTime);
return {
hasConflict: conflicts.length > 0,
conflictingWindowRefs: conflicts,
suggestedAlternativeWindows: [],
};
};
registerDetector("same-start", myMode);
console.log(getRegisteredModes()); // ["same-start"]
```
## API Reference
### `registerDetector(mode: string, detector: ConflictDetectorFn): void`
Inserts the detector into the module-level registry. Idempotent — re-registering replaces.
### `detectConflicts(proposed, existing, config): OverlapResult`
Looks up `config.mode` in the registry and delegates. Throws `BookingError({ code: BOOKING.UNKNOWN_CONFLICT_MODE, mode })` if no detector is registered for that mode.
### `getRegisteredModes(): string[]`
Snapshot of the registry's keys.
### `clearDetectorRegistry(): void`
Empties the registry. Primarily for tests that need an isolated starting state.
### Types
- `OverlapResult` — `{ hasConflict, conflictingWindowRefs, suggestedAlternativeWindows }`.
- `ConflictDetectionMode` — `"exact_match" | "range_overlap" | "slot_capacity" | "provider_overlap" | "composite"`.
- `ConflictDetectorFn` — `(proposed, existing, config) => OverlapResult`.
## Implementation Notes
- The registry is module-level (singleton) for parity with upstream. A future change can scope it to an engine instance if consumers need isolation.
- The dispatch carries the original `config` through to the detector unchanged so detectors can read `bufferDuration`, `allowSameBoundaryTurnover`, etc.
- Built-in detectors do not auto-register themselves at import time — call `registerDetector(mode, fn)` explicitly so the engine consumer controls which modes are active.

View File

@@ -0,0 +1,61 @@
# exact-match
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Conflict-detection strategy for seat-style bookings where two reservations conflict only when their start AND end times match exactly. This is the right mode for stadiums, theatres, cinemas, and flights — domains where a seat is bookable for a specific event window and "overlapping" only means "claiming the same instance of the same seat." Compared to `range_overlap`, this mode is strictly stricter; it never flags a partial overlap as a conflict.
## Features
- O(n) scan over existing windows.
- Pure function; no side effects.
- Ignores `bufferDuration` and `allowSameBoundaryTurnover` (intentionally).
- Returns the standard `OverlapResult` shape.
## Usage: Basic
```ts
import { detectExactMatch, registerDetector } from "@suaveplan/booking";
registerDetector("exact_match", detectExactMatch);
```
## Usage: Advanced
```ts
import { detectExactMatch } from "@suaveplan/booking";
const proposed = {
startTime: "2026-05-30T19:00:00Z",
endTime: "2026-05-30T22:00:00Z",
timezone: "Europe/London",
};
const existing = [
{ startTime: "2026-05-30T19:00:00Z", endTime: "2026-05-30T22:00:00Z", timezone: "Europe/London" },
{ startTime: "2026-05-30T19:00:00Z", endTime: "2026-05-30T21:00:00Z", timezone: "Europe/London" },
];
const result = detectExactMatch(proposed, existing, {
mode: "exact_match", allowSameBoundaryTurnover: false,
bufferDuration: "PT0S", holdDuration: "PT0M", capacityMode: "absolute",
});
// result.hasConflict === true; result.conflictingWindowRefs.length === 1 (only the exact match)
```
## API Reference
### `detectExactMatch(proposed, existing, _config): OverlapResult`
Returns `{ hasConflict, conflictingWindowRefs, suggestedAlternativeWindows: [] }`. A window in `existing` is conflicting iff both its start and its end timestamps equal `proposed.startTime` and `proposed.endTime` respectively (in milliseconds).
## Implementation Notes
- Timestamps are compared via `Date.parse(...)` to handle different ISO formats normalising to the same instant.
- Suggested alternatives are intentionally empty — the engine layer can compose alternatives from slot inventory; the detector's job is "is there a conflict?".
- The `_config` parameter is accepted to satisfy the `ConflictDetectorFn` signature; this mode ignores buffer / turnover semantics.
- The detector is fully pure and stateless — safe to call from many call sites concurrently without coordination, including the hot path of high-throughput ticketing flows.
- For multi-seat group sales where a single request covers many seats, callers iterate this detector per seat rather than combining seats into a single call; the detector is intentionally narrow-scope to keep the math obvious.
- Pairing this detector with `exactMatchStrategy` in `src/availability/strategies/strategies.ts` gives a complete seat-style availability + conflict pipeline that handles the canonical "double-book the same seat" failure mode without ambiguity.

View File

@@ -0,0 +1,62 @@
# provider-overlap
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Conflict-detection strategy for appointment-style bookings where the constraint is "this provider's calendar." Used for medical, dental, salon, and consulting domains where a provider sees one client at a time and needs buffer time between appointments. The implementation extends every existing booking by `bufferDuration` on each side and reports a conflict whenever the proposed window touches the extended range.
## Features
- Symmetric buffer extension via `ConflictDetectorConfig.bufferDuration`.
- Half-open overlap inside the extended range.
- Pure function; no side effects.
- Returns the standard `OverlapResult` shape.
## Usage: Basic
```ts
import { detectProviderOverlap, registerDetector } from "@suaveplan/booking";
registerDetector("provider_overlap", detectProviderOverlap);
```
## Usage: Advanced
```ts
import { detectProviderOverlap } from "@suaveplan/booking";
const proposed = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T10:30:00Z",
timezone: "UTC",
};
const previousAppointment = [
{ startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T09:50:00Z", timezone: "UTC" },
];
// 15-minute buffer: previous ends 09:50, extended to 10:05 → conflicts with the 10:00 start.
detectProviderOverlap(proposed, previousAppointment, {
mode: "provider_overlap", allowSameBoundaryTurnover: false,
bufferDuration: "PT15M", holdDuration: "PT5M", capacityMode: "absolute",
}).hasConflict; // true
```
## API Reference
### `detectProviderOverlap(proposed, existing, config): OverlapResult`
Returns `{ hasConflict, conflictingWindowRefs, suggestedAlternativeWindows: [] }`. A window in `existing` is conflicting iff its `[start - bufferMs, end + bufferMs)` range overlaps `proposed`.
## Implementation Notes
- `bufferDuration` is parsed via `parseDurationMs`; the same ISO-8601 subset is used throughout the package.
- The detector ignores `allowSameBoundaryTurnover` because provider-overlap semantics require a buffer to be honoured at the boundary too.
- `conflictingWindowRefs` carries the original existing windows; the buffer is purely a comparison artefact.
- Pass an empty `existing[]` for new providers to skip the scan entirely.
- The detector is fully pure and stateless — safe to call from many call sites concurrently without coordination, including hot-path booking flows.
- For domains with per-bookable buffer overrides (a 5-minute buffer for haircuts but 30 minutes for surgeries) consumers wrap this detector and read the bookable's `attributes.bufferBeforeMinutes` / `bufferAfterMinutes` to compute a per-call `bufferDuration`.
- When pairing with `enableRecurring: true` (the appointment preset's default), make sure recurrence expansion runs BEFORE this detector so every materialised occurrence appears in `existing`.
- The half-open semantics inside the buffered range mean that two appointments touching at the buffered boundary (e.g. `[09:00,09:30)` + 5min buffer = `[08:55,09:35)`, second is `[09:35,10:00)`) are NOT considered conflicting; the first ends exactly when the buffered range ends.

View File

@@ -0,0 +1,65 @@
# range-overlap
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Conflict-detection strategy for accommodation-style bookings where any temporal overlap counts as a conflict. Models hotel rooms, vacation rentals, hostels, and any other domain where a single bookable can host at most one booking at a time and bookings span continuous date ranges. Honours both `bufferDuration` (e.g. 4-hour cleaning turnover) and `allowSameBoundaryTurnover` (whether check-out at 11 followed by check-in at 14 counts as a conflict).
## Features
- Half-open `[start, end)` overlap test.
- Optional buffer extends the existing window symmetrically.
- `allowSameBoundaryTurnover` excludes boundary-touching cases when true.
- Pure function; no side effects.
## Usage: Basic
```ts
import { detectRangeOverlap, registerDetector } from "@suaveplan/booking";
registerDetector("range_overlap", detectRangeOverlap);
```
## Usage: Advanced
```ts
import { detectRangeOverlap } from "@suaveplan/booking";
const proposed = {
startTime: "2026-06-03T14:00:00Z",
endTime: "2026-06-05T11:00:00Z",
timezone: "UTC",
};
const existing = [
{ startTime: "2026-06-01T14:00:00Z", endTime: "2026-06-03T11:00:00Z", timezone: "UTC" },
];
// With turnover allowed, 11→14 gap is fine: no conflict.
detectRangeOverlap(proposed, existing, {
mode: "range_overlap", allowSameBoundaryTurnover: true,
bufferDuration: "PT0S", holdDuration: "PT10M", capacityMode: "absolute",
}).hasConflict; // false
// With a 4-hour cleaning buffer and turnover disallowed, the existing window
// extends to 15:00, conflicting with the 14:00 proposed start.
detectRangeOverlap(proposed, existing, {
mode: "range_overlap", allowSameBoundaryTurnover: false,
bufferDuration: "PT4H", holdDuration: "PT15M", capacityMode: "absolute",
}).hasConflict; // true
```
## API Reference
### `detectRangeOverlap(proposed, existing, config): OverlapResult`
Returns `{ hasConflict, conflictingWindowRefs, suggestedAlternativeWindows: [] }`. A window in `existing` is conflicting iff its buffer-extended range overlaps `proposed` AND (when `allowSameBoundaryTurnover === true`) the proposed window does not touch the existing window's ORIGINAL (un-buffered) start/end.
## Implementation Notes
- The buffer is symmetric — `bufferDuration` extends both the start and end of the existing window — so the parameter models cleaning/preparation time on either side.
- When `allowSameBoundaryTurnover === true`, the boundary check uses the original (un-buffered) start/end so the policy works regardless of buffer size.
- `bufferDuration` is parsed via `parseDurationMs` — the ISO-8601 subset (D, H, M, S) is the package's standard duration syntax.
- The detector returns the buffered windows in `conflictingWindowRefs`? No — it returns the original existing windows; only the comparison is buffered. Callers debug their data, not the buffer arithmetic.

View File

@@ -0,0 +1,73 @@
# slot-capacity
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Conflict-detection strategy for group bookings — meeting rooms, tour groups, activity classes, anything where multiple bookings can share a slot up to a capacity limit. Unlike `exact_match` and `range_overlap`, this strategy never says "no" purely on the basis of overlap; it reports a conflict only when the proposed booking would push the total occupancy past `totalCapacity`. When no `CapacityContext` is supplied, the detector returns `hasConflict: false` because it cannot make a capacity decision.
## Features
- Overlap detection scoped to slots whose window intersects the proposed window.
- Capacity arithmetic via `CapacityContext { totalCapacity, currentBookedQuantity, requestedQuantity }`.
- Defensive default — no context = no conflict reported.
- Pure function; no side effects.
## Usage: Basic
```ts
import { detectSlotCapacity, registerDetector } from "@suaveplan/booking";
registerDetector("slot_capacity", detectSlotCapacity);
```
## Usage: Advanced
```ts
import { detectSlotCapacity } from "@suaveplan/booking";
const proposed = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
};
const overlapping = [
{ startTime: "2026-06-01T10:00:00Z", endTime: "2026-06-01T11:00:00Z", timezone: "UTC" },
];
// Class capacity 20, already 18 booked, asking for 5 more
const tooFull = detectSlotCapacity(proposed, overlapping, {
mode: "slot_capacity", allowSameBoundaryTurnover: false,
bufferDuration: "PT0S", holdDuration: "PT10M", capacityMode: "absolute",
}, {
totalCapacity: 20,
currentBookedQuantity: 18,
requestedQuantity: 5,
});
// tooFull.hasConflict === true, tooFull.conflictingWindowRefs.length === 1
```
## API Reference
### `detectSlotCapacity(proposed, existing, _config, context?): OverlapResult`
| Parameter | Type | Notes |
|---|---|---|
| `proposed` | `TimeWindow` | The new booking window. |
| `existing` | `TimeWindow[]` | All existing bookings; only overlapping ones are considered. |
| `_config` | `ConflictDetectorConfig` | Accepted for signature parity; not consulted. |
| `context?` | `CapacityContext` | When provided, drives the conflict decision. |
Returns `{ hasConflict, conflictingWindowRefs, suggestedAlternativeWindows: [] }`. `conflictingWindowRefs` is populated only when `hasConflict` is true — callers can use it to surface "you're competing with these other group bookings".
### `CapacityContext`
`{ totalCapacity, currentBookedQuantity, requestedQuantity }`. Used to decide `currentBookedQuantity + requestedQuantity > totalCapacity`.
## Implementation Notes
- Without `context`, the detector cannot do capacity math; it returns `hasConflict: false` so the engine doesn't reject a request the caller hasn't actually pre-checked.
- Overlap uses half-open intervals; a slot ending exactly when another begins is NOT considered overlapping.
- The detector returns the original `existing` windows in `conflictingWindowRefs`, not buffered or adjusted versions.

View File

@@ -0,0 +1,104 @@
# engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BookingEngine` is the booking system's pipeline orchestrator. It composes the registry of bookables, the persistent map of confirmed bookings + holds + waitlist entries, the `StateMachine` (driven by `BookingSystemConfig.statusTransitions`), the `EventBus` (emitting 15 lifecycle events), and the in-package `Hooks<BookingHookMap>` (transforming `booking:creating` and `booking:cancelling` data) into a single typed entry point that consumers wire from their domain layer. Every public method raises a `BookingError` on failure so callers can dispatch on stable codes (`BOOKING.ENGINE_NOT_CONFIGURED`, `BOOKING.BOOKING_NOT_FOUND`, `BOOKING.BOOKING_CONFLICT`, `BOOKING.TRANSITION_NOT_ALLOWED`).
## Features
- Generic over `TBookableMeta` and `TBookingMeta` for typed metadata flow-through.
- 17 public methods + the `bookingHooks` accessor exposing the engine's `Hooks` instance.
- In-memory `Map<id, …>` registries for bookables, bookings, and holds.
- Typed errors on every failure path.
- Pluggable hooks pipeline for transforming create/cancel data before persistence.
## Usage: Basic
```ts
import { createBookingEngine, createSeatConfig } from "@suaveplan/booking";
const engine = createBookingEngine();
engine.configure(createSeatConfig("arena", "Main Arena"));
```
## Usage: Advanced
```ts
import {
BookingError, BOOKING_CODES, createBookingEngine, createSeatConfig,
} from "@suaveplan/booking";
const engine = createBookingEngine();
engine.configure(createSeatConfig("arena", "Main Arena"));
engine.bookingHooks.hook("booking:creating", (data) => ({
...data, customerRef: data.customerRef.toLowerCase(),
}));
engine.on("booking:confirmed", (event) => {
console.log("Confirmed:", event.payload);
});
engine.registerBookable({
identifierRef: { id: "seat-A12", tenantId: "arena", categoryId: "vip" },
name: "Seat A12", description: "Aisle", bookableType: "seat",
status: "active", layoutNodeRef: null, capacityRef: "cap-A12",
priceRuleRefs: [], timeSlotRefs: [], attributes: {},
});
try {
const booking = engine.confirmBooking({
bookableRef: "seat-A12",
customerRef: "Customer-42",
windowRef: { startTime: "2026-06-01T19:00:00Z", endTime: "2026-06-01T22:00:00Z", timezone: "UTC" },
quantity: 1,
});
console.log("Booking id:", booking.identifierRef.id);
console.log("Customer (lowercased):", booking.customerRef);
} catch (err) {
if (err instanceof BookingError && err.code === BOOKING_CODES.BOOKING_CONFLICT) {
console.warn("Conflict with", err.bookingId);
}
}
```
## API Reference
### `class BookingEngine<TBookableMeta, TBookingMeta>`
| Method | Returns | Throws |
|---|---|---|
| `configure(config)` | `void` | — |
| `bookingHooks` (getter) | `Hooks<BookingHookMap>` | — |
| `registerBookable(b)` | `void` | `ENGINE_NOT_CONFIGURED` |
| `getBookable(id)` | `Bookable \| null` | — |
| `listBookables(query)` | `BookableQueryResult` | — |
| `checkAvailability(query)` | `AvailabilityResult` | `ENGINE_NOT_CONFIGURED` |
| `getAvailableSlots(id, window)` | `TimeSlot[]` | `ENGINE_NOT_CONFIGURED` |
| `createHold(req)` | `BookingHold` | `ENGINE_NOT_CONFIGURED` |
| `confirmBooking(req)` | `Booking` | `ENGINE_NOT_CONFIGURED`, `BOOKING_CONFLICT` |
| `cancelBooking(id, reason)` | `CancellationResult` | `ENGINE_NOT_CONFIGURED`, `BOOKING_NOT_FOUND`, `TRANSITION_NOT_ALLOWED` |
| `modifyBooking(id, changes)` | `Booking` | `ENGINE_NOT_CONFIGURED`, `BOOKING_NOT_FOUND` |
| `calculatePrice(req)` | `BookingPrice` | `ENGINE_NOT_CONFIGURED` |
| `joinWaitlist(req)` | `WaitlistEntry` | `ENGINE_NOT_CONFIGURED` |
| `on(event, handler)` | `string` | — |
| `off(event, handlerOrId)` | `void` | — |
| `getBooking(id)` | `Booking \| null` | — |
| `getHold(id)` | `BookingHold \| null` | — |
### `createBookingEngine<TBookableMeta, TBookingMeta>(): BookingEngine<…>`
Factory; equivalent to `new BookingEngine()`.
## Implementation Notes
- Bookables, bookings, and holds are stored in in-memory `Map<>`s; consumers wanting persistence wrap their own backing store.
- The engine consults its `StateMachine` (built from `config.statusTransitions`) on cancellation; the role is hard-coded to `"customer"` to match upstream. Future change can thread the caller's role through.
- Hooks fire BEFORE availability and pricing checks, so handlers can rewrite the booking shape (e.g. lowercase email, expand quantity) without engine-side validation re-runs.
- `confirmBooking` deletes the supplied `holdId` before checking availability so a held window does not flag itself as a conflict.
- `checkAvailability` returns `{ available: false, ... }` (not throw) when the bookable is unknown — the engine treats unknown bookables as fully booked from the consumer's perspective.
- `getAvailableSlots` uses a fixed 1-hour slot duration; consumers wanting variable slot duration call `generateTimeSlots` from `src/temporal/slots/` directly.

View File

@@ -0,0 +1,80 @@
# event-bus
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Typed publish/subscribe event bus for booking lifecycle events. The engine emits the 15 standard `BookingEventType` events (booking confirmed, cancelled, held, waitlisted, …) through this bus; consumers register handlers via `on(eventType, handler)` and receive a wrapped `BookingEvent { eventType, timestamp, tenantId, payload }`. Subscriptions can be removed by id or by handler reference; the bus carries no global state beyond its own subscription map.
## Features
- `EventBus` class + `createEventBus` factory.
- Typed `on<TPayload>(eventType, handler) => string` returning a subscription id.
- `off` accepts either the id or the handler reference.
- `emit<TPayload>(eventType, tenantId, payload)` wraps payload into `BookingEvent`.
- `clear()` drops every subscription.
- `listenerCount(eventType)` and `eventTypes()` introspection.
## Usage: Basic
```ts
import { createEventBus } from "@suaveplan/booking";
const bus = createEventBus();
const id = bus.on("booking:confirmed", (event) => {
console.log("Confirmed", event.payload);
});
bus.emit("booking:confirmed", "tenant-1", { bookingId: "abc" });
bus.off("booking:confirmed", id);
```
## Usage: Advanced
```ts
import { createEventBus } from "@suaveplan/booking";
import type { Booking, BookingEventHandler } from "@suaveplan/booking";
const bus = createEventBus();
const onConfirmed: BookingEventHandler<Booking> = (event) => {
sendReceipt(event.payload.identifierRef.id, event.payload.customerRef);
};
bus.on("booking:confirmed", onConfirmed);
bus.on("hold:created", (e) => console.log("hold", e.payload));
// Remove by handler reference (no id needed)
bus.off("booking:confirmed", onConfirmed);
// Observability snapshot
console.log({
types: bus.eventTypes(),
holdListeners: bus.listenerCount("hold:created"),
});
```
## API Reference
### `class EventBus`
| Method | Returns | Notes |
|---|---|---|
| `on<TPayload>(eventType, handler)` | `string` | Subscription id. |
| `off(eventType, handlerOrId)` | `void` | Accepts id OR handler reference. |
| `emit<TPayload>(eventType, tenantId, payload)` | `void` | Wraps payload in `BookingEvent`. |
| `clear()` | `void` | Drops every subscription. |
| `listenerCount(eventType)` | `number` | 0 if none. |
| `eventTypes()` | `BookingEventType[]` | Currently subscribed types. |
### `createEventBus(): EventBus`
Factory. Equivalent to `new EventBus()`.
## Implementation Notes
- Subscriptions are stored as `Map<BookingEventType, EventSubscription[]>` so dispatch is O(n) over handlers for one event, never over all events.
- `generateSubscriptionId` produces process-unique ids via a module-level counter + `Date.now()` — adequate for in-memory tracking; replace if multi-process correlation is needed.
- `emit` is synchronous; handlers run in registration order. Uncaught exceptions propagate to the emitter — wrap handler bodies in try/catch if necessary.
- The bus does not buffer — emitting before any subscription is registered drops the event silently. Consumers needing replay semantics should layer their own store on top.

View File

@@ -0,0 +1,82 @@
# state-machine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Configurable booking-status state machine. Consumers pass an array of `StatusTransition` rows (from `BookingSystemConfig.statusTransitions`) to the constructor; the machine then answers `canTransition`, lists available transitions per role, surfaces auto-transition candidates, and enumerates every known status. Because the rules are pure data, the same engine instance can serve every domain (seats, accommodation, appointments, …) without subclassing.
## Features
- `StateMachine` class + `createStateMachine` factory.
- `canTransition(request)` — returns `{ allowed, from, to, message }`.
- Three failure modes — undefined transition, role-restricted, reason-required.
- `getAvailableTransitions(from, role)` — every transition currently legal for a role.
- `getAutoTransitions(from)` — transitions carrying `autoTransitionAfter` (consumers schedule the firing).
- `getAllStatuses()` — union of every `from` and `to` referenced.
## Usage: Basic
```ts
import { createStateMachine } from "@suaveplan/booking";
const sm = createStateMachine([
{ from: "pending", to: "confirmed", allowedRoles: ["system"], requiresReason: false, autoTransitionAfter: null },
{ from: "confirmed", to: "cancelled", allowedRoles: ["customer"], requiresReason: true, autoTransitionAfter: null },
]);
sm.canTransition({ from: "pending", to: "confirmed", role: "system" });
// { allowed: true, ... }
```
## Usage: Advanced
```ts
import { StateMachine } from "@suaveplan/booking";
const sm = new StateMachine(cfg.statusTransitions);
// What can a staff member do to a checked-in booking?
const next = sm.getAvailableTransitions("checked_in", "staff");
// [{ from: "checked_in", to: "completed", ... }]
// Auto-cancel anything that's been confirmed for 24h without check-in
const autos = sm.getAutoTransitions("confirmed");
for (const t of autos) scheduleAutoFire(t.to, t.autoTransitionAfter);
// What statuses exist altogether?
const all = sm.getAllStatuses();
// ["pending", "confirmed", "checked_in", "completed", "cancelled", "no_show"]
```
## API Reference
### `class StateMachine`
| Method | Returns | Notes |
|---|---|---|
| `canTransition(request)` | `TransitionResult` | Single source of truth for "can X happen?". |
| `getAvailableTransitions(from, role)` | `StatusTransition[]` | Filtered by `from` AND `allowedRoles.includes(role)`. |
| `getAutoTransitions(from)` | `StatusTransition[]` | Filtered by `from` AND `autoTransitionAfter !== null`. |
| `getAllStatuses()` | `string[]` | Union of every from/to reference. |
### `TransitionRequest`
`{ from, to, role, reason? }`. `reason` is optional but required for transitions whose row has `requiresReason: true`.
### `TransitionResult`
`{ allowed, from, to, message }`. `message` is human-readable; consumers can surface it directly.
### `createStateMachine(transitions): StateMachine`
Convenience factory; equivalent to `new StateMachine(transitions)`.
## Implementation Notes
- The machine does not enforce auto-transitions itself — `autoTransitionAfter` is metadata for consumers to schedule.
- `canTransition` returns `allowed: false` rather than throwing because the state-machine is consulted on every cancellation/modification request and exceptions would hurt observability.
- Failure messages name the relevant field (transition / role / reason) so consumers can surface accurate diagnostics to end-users.
- `getAllStatuses` uses a `Set` for deduplication; the returned array order matches insertion order.

View File

@@ -0,0 +1,77 @@
# accommodation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the accommodation booking domain — hotels, vacation rentals, hostels. Each bookable represents one bookable room that hosts at most one booking at a time across continuous date ranges. The preset configures `conflictRef.mode = "range_overlap"` with `allowSameBoundaryTurnover: true` and a 4-hour cleaning `bufferDuration`, plus a 15-minute hold default. The `maxAdvanceBookingDays` is raised to 365 to accommodate next-year travel planning. The `bookableTypeRegistry` defines required (`roomNumber`, `roomTypeId`, `maxOccupancy`) and optional (`floor`, `viewType`) attributes.
## Features
- `createAccommodationConfig(tenantId, tenantName, overrides?)` — preset factory.
- `AccommodationBookableMeta` — typed attribute bag for accommodation bookables.
- Defaults `pricingModel` to `"per_duration"`, `conflictMode` to `"range_overlap"`.
- 4-hour cleaning buffer, boundary turnover allowed.
- 365-day advance booking window.
## Usage: Basic
```ts
import { createAccommodationConfig } from "@suaveplan/booking";
const cfg = createAccommodationConfig("hotel-1", "Seaside Hotel");
```
## Usage: Advanced
```ts
import { createAccommodationConfig } from "@suaveplan/booking";
import type { AccommodationBookableMeta, Bookable } from "@suaveplan/booking";
const cfg = createAccommodationConfig("hotel-1", "Seaside Hotel", {
pricingRef: {
defaultCurrency: "EUR",
supportedCurrencies: ["EUR", "USD", "GBP"],
taxStrategy: "inclusive",
roundingMode: "ceil",
roundingPrecision: 2,
},
});
const room101: Bookable<AccommodationBookableMeta> = {
identifierRef: { id: "room-101", tenantId: "hotel-1", categoryId: "deluxe" },
name: "Deluxe Sea View 101",
description: "King bed, balcony",
bookableType: "hotel-room",
status: "active",
layoutNodeRef: "node-room-101",
capacityRef: "cap-room-101",
priceRuleRefs: ["rate-nightly"],
timeSlotRefs: [],
attributes: {
roomNumber: "101", roomTypeId: "deluxe",
floor: 1, bedConfiguration: "king",
maxOccupancy: 2, viewType: "sea",
amenityIds: ["wifi", "minibar", "ac"],
},
};
```
## API Reference
### `createAccommodationConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a `BookingSystemConfig` based on `createBaseConfig` with accommodation-specific overrides (range_overlap mode, 4h buffer, 365-day advance) applied first, then user `overrides` applied last.
### `AccommodationBookableMeta`
`{ roomNumber, roomTypeId, floor, bedConfiguration, maxOccupancy, amenityIds, viewType }`.
## Implementation Notes
- `allowSameBoundaryTurnover: true` lets check-out at 11 and check-in at 14 coexist for the same room without conflicting at the boundary.
- The 4-hour cleaning buffer is symmetric — it extends existing windows on both sides for overlap checks.
- `defaultPricingModel: "per_duration"` flags that the calculator should treat `unitDuration` as the rate unit (typically `"P1D"` for nightly).
- Consumers can switch to per-stay (fixed) pricing via per-rule overrides without modifying the preset.

View File

@@ -0,0 +1,74 @@
# activities
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the activity/tour booking domain — group tours, classes, workshops, group events. Each bookable represents a scheduled group activity with min/max participant counts. The preset configures `conflictRef.mode = "slot_capacity"`, `engineRef.enableWaitlist: true` (popular tours commonly overflow), and a `bookableTypeRegistry` entry with required (`activityTypeId`, `maxParticipants`) and optional (`minParticipants`, `instructorRef`, `difficultyLevel`) attributes.
## Features
- `createActivityConfig(tenantId, tenantName, overrides?)` — preset factory.
- `ActivityBookableMeta` — typed attribute bag.
- Defaults `pricingModel` to `"per_unit"` (per participant), `conflictMode` to `"slot_capacity"`, `supportsLayout: false`.
- `enableWaitlist: true` by default.
## Usage: Basic
```ts
import { createActivityConfig } from "@suaveplan/booking";
const cfg = createActivityConfig("studio-1", "Mountain Adventures");
```
## Usage: Advanced
```ts
import { createActivityConfig } from "@suaveplan/booking";
import type { ActivityBookableMeta, Bookable } from "@suaveplan/booking";
const cfg = createActivityConfig("studio-1", "Mountain Adventures");
const climbing: Bookable<ActivityBookableMeta> = {
identifierRef: { id: "tour-climb-friday", tenantId: "studio-1", categoryId: "outdoor" },
name: "Friday Climbing Tour",
description: "Half-day intermediate climb",
bookableType: "activity",
status: "active",
layoutNodeRef: null,
capacityRef: "cap-climb-friday",
priceRuleRefs: ["rate-perperson"],
timeSlotRefs: [],
attributes: {
activityTypeId: "climbing",
instructorRef: "guide-mary",
minParticipants: 3,
maxParticipants: 8,
difficultyLevel: "intermediate",
equipmentIncluded: true,
meetingPointRef: "base-camp-A",
},
};
```
## API Reference
### `createActivityConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a config with activity-specific defaults (slot_capacity mode, per_unit pricing, waitlist enabled).
### `ActivityBookableMeta`
`{ activityTypeId, instructorRef, minParticipants, maxParticipants, difficultyLevel, equipmentIncluded, meetingPointRef }`.
## Implementation Notes
- `per_unit` pricing means the calculator multiplies the rate by the booking `quantity` (participants), matching standard per-person tour pricing.
- `minParticipants` is metadata only — the engine does not cancel undersold activities itself; consumers schedule cancellation logic.
- `equipmentIncluded` and `meetingPointRef` give the UI extra context for confirmation emails without requiring a custom schema.
- `supportsLayout: false` because group activities typically don't have a spatial selection UI.
- The default `engineRef.maxAdvanceBookingDays` of 90 (inherited from the base preset) suits short-cycle activity bookings well; long-lead expeditions or trekking trips that need 6-month visibility override this through the third argument.
- Pair the activity preset with `slot_capacity` conflict detection plus a custom `CapacityContext` that reads `attributes.maxParticipants` per bookable for accurate occupancy math.
- Consumers integrating with notification systems can attach hooks on `engine.bookingHooks` to send instructor-assignment confirmations at create time and waitlist-promotion alerts on cancellation.

View File

@@ -0,0 +1,75 @@
# appointments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the appointment booking domain — medical, dental, salon, consulting. Each bookable represents an appointment slot tied to a service provider whose calendar must never double-book. The preset configures `conflictRef.mode = "provider_overlap"` with a 15-minute buffer, sets `engineRef.enableRecurring` and `enableWaitlist` to true (recurring appointments and overflow queues are standard in this domain), and registers a `bookableTypeRegistry` entry with required (`providerId`, `serviceTypeId`, `durationMinutes`) and optional (`locationId`, `bufferBeforeMinutes`, `bufferAfterMinutes`) attributes.
## Features
- `createAppointmentConfig(tenantId, tenantName, overrides?)` — preset factory.
- `AppointmentBookableMeta` — typed attribute bag (extends to allow-concurrent semantics).
- Defaults `pricingModel` to `"fixed"`, `conflictMode` to `"provider_overlap"`, `supportsLayout: false`.
- 15-minute buffer between back-to-back appointments.
- Recurring AND waitlist enabled by default.
## Usage: Basic
```ts
import { createAppointmentConfig } from "@suaveplan/booking";
const cfg = createAppointmentConfig("clinic-1", "Heartbeat Clinic");
```
## Usage: Advanced
```ts
import { createAppointmentConfig } from "@suaveplan/booking";
import type { AppointmentBookableMeta, Bookable } from "@suaveplan/booking";
const cfg = createAppointmentConfig("clinic-1", "Heartbeat Clinic", {
holdRef: { enabled: true, defaultDurationMinutes: 5, maxDurationMinutes: 10, autoReleaseEnabled: true },
});
const checkup: Bookable<AppointmentBookableMeta> = {
identifierRef: { id: "appt-dr-jones", tenantId: "clinic-1", categoryId: "general" },
name: "GP Consultation",
description: "Routine checkup with Dr. Jones",
bookableType: "appointment",
status: "active",
layoutNodeRef: null,
capacityRef: "cap-dr-jones",
priceRuleRefs: ["rate-consult"],
timeSlotRefs: [],
attributes: {
providerId: "dr-jones",
serviceTypeId: "checkup",
durationMinutes: 30,
locationId: "clinic-east",
bufferBeforeMinutes: 5,
bufferAfterMinutes: 10,
allowConcurrent: false,
maxConcurrent: 1,
},
};
```
## API Reference
### `createAppointmentConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a config with appointment-specific defaults (provider_overlap, 15-min buffer, recurring + waitlist enabled).
### `AppointmentBookableMeta`
`{ providerId, serviceTypeId, durationMinutes, locationId, bufferBeforeMinutes, bufferAfterMinutes, allowConcurrent, maxConcurrent }`.
## Implementation Notes
- `enableRecurring: true` reflects the common medical-clinic pattern of weekly therapy slots; the engine doesn't auto-generate occurrences but `expandRecurrence` from `src/temporal/recurrence/` is the right helper for consumers.
- `bufferDuration: "PT15M"` applies engine-wide for this preset; per-bookable buffer fields (`bufferBeforeMinutes`/`bufferAfterMinutes`) give finer-grained control consumers can read from the attribute bag.
- `supportsLayout: false` because providers do not need spatial visualisation.
- `enableWaitlist: true` because clinics regularly queue patients for next-available appointments.

View File

@@ -0,0 +1,83 @@
# resources
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the resource booking domain — meeting rooms, equipment, shared vehicles. Each bookable represents a resource with a typed capacity that can be partially consumed by overlapping bookings. The preset configures `conflictRef.mode = "slot_capacity"` (so the engine reports conflicts only when total occupancy would exceed capacity), `engineRef.domainPreset = "resources"`, and a `bookableTypeRegistry` entry with required (`resourceTypeId`, `capacity`) and optional (`locationId`, `requiresApproval`) attributes.
## Features
- `createResourceConfig(tenantId, tenantName, overrides?)` — preset factory.
- `ResourceBookableMeta` — typed attribute bag.
- Defaults `pricingModel` to `"per_duration"`, `conflictMode` to `"slot_capacity"`, `supportsLayout: true`.
- No buffer, 10-min hold.
## Usage: Basic
```ts
import { createResourceConfig } from "@suaveplan/booking";
const cfg = createResourceConfig("co-1", "Coworking Space");
```
## Usage: Advanced
```ts
import { createResourceConfig } from "@suaveplan/booking";
import type { Bookable, ResourceBookableMeta } from "@suaveplan/booking";
const cfg = createResourceConfig("co-1", "Coworking Space", {
engineRef: {
domainPreset: "resources",
customStrategies: [],
enableHolds: true,
enableWaitlist: true,
enableRecurring: true,
maxBookingsPerCustomer: 50,
maxAdvanceBookingDays: 60,
},
});
const meetingRoom: Bookable<ResourceBookableMeta> = {
identifierRef: { id: "room-large-conference", tenantId: "co-1", categoryId: "meeting-room" },
name: "Large Conference Room",
description: "Seats 20",
bookableType: "resource",
status: "active",
layoutNodeRef: "node-room-1",
capacityRef: "cap-room-1",
priceRuleRefs: ["rate-hourly"],
timeSlotRefs: [],
attributes: {
resourceTypeId: "meeting-room",
locationId: "floor-3",
capacity: 20,
featureIds: ["projector", "whiteboard", "video-conferencing"],
requiresApproval: false,
approverRef: "",
},
};
```
## API Reference
### `createResourceConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a config with resource-specific defaults (slot_capacity mode, per_duration pricing).
### `ResourceBookableMeta`
`{ resourceTypeId, locationId, capacity, featureIds, requiresApproval, approverRef }`.
## Implementation Notes
- `slot_capacity` mode allows multiple bookings to share a resource up to the per-bookable `capacity` field (the engine reads it from `attributes` via the `slot_capacity` detector's `CapacityContext`).
- `requiresApproval` is metadata only — the engine does not enforce approval workflows; consumers gate confirmation on this field.
- `supportsLayout: true` lets floor-plan UIs render the resource as a clickable area.
- `per_duration` pricing means hourly/daily rates with `unitDuration` driving the unit.
- Resources commonly need recurring bookings (a meeting room booked every Tuesday); pair the preset with the recurrence helpers from `@suaveplan/booking/temporal/recurrence/recurrence` to materialise occurrences in advance.
- The `featureIds` attribute is a free-form bag of capability tokens (projector, whiteboard, video-conferencing). Search UIs can filter resources by feature without the engine knowing what each feature means.
- When `requiresApproval` is true, consumers typically combine the preset with a `BookingRule` (action `deny` until status flips to `approved`) so the engine treats unapproved bookings as conflicts.

View File

@@ -0,0 +1,77 @@
# seats
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the seat-based booking domain — stadiums, theatres, cinemas, flights. Each bookable represents one physical seat that can be booked at most once per event window. The preset configures `conflictRef.mode = "exact_match"` (because the only conflict for a seat is "same seat, same event"), `engineRef.domainPreset = "seats"`, and a `bookableTypeRegistry` entry describing the required (`seatNumber`, `rowLabel`, `sectionLabel`) and optional (`tier`, `isAccessible`, `hasObstructedView`) attributes for a seat. The companion `SeatBookableMeta` type exposes the attribute shape consumers should attach to `Bookable<SeatBookableMeta>`.
## Features
- `createSeatConfig(tenantId, tenantName, overrides?)` — preset factory.
- `SeatBookableMeta` — typed attribute bag for seat bookables.
- Defaults `pricingModel` to `"fixed"`, `conflictMode` to `"exact_match"`, `supportsLayout` to `true`.
- Three required attributes (`seatNumber`, `rowLabel`, `sectionLabel`), three optional (`tier`, `isAccessible`, `hasObstructedView`).
## Usage: Basic
```ts
import { createSeatConfig } from "@suaveplan/booking";
const cfg = createSeatConfig("arena-1", "Main Arena");
```
## Usage: Advanced
```ts
import { createSeatConfig } from "@suaveplan/booking";
import type { Bookable, SeatBookableMeta } from "@suaveplan/booking";
const cfg = createSeatConfig("arena-1", "Main Arena", {
engineRef: {
domainPreset: "seats",
customStrategies: [],
enableHolds: true,
enableWaitlist: true,
enableRecurring: false,
maxBookingsPerCustomer: 8,
maxAdvanceBookingDays: 180,
},
});
const seatA12: Bookable<SeatBookableMeta> = {
identifierRef: { id: "seat-A12", tenantId: "arena-1", categoryId: "lower-bowl" },
name: "Seat A12",
description: "Aisle seat, lower bowl",
bookableType: "seat",
status: "active",
layoutNodeRef: "node-seat-A12",
capacityRef: "cap-seat-A12",
priceRuleRefs: ["rate-standard"],
timeSlotRefs: [],
attributes: {
seatNumber: "12", rowLabel: "A", sectionLabel: "Lower Bowl",
tier: "premium", isAccessible: true, hasObstructedView: false,
adjacentSeatIds: ["seat-A11", "seat-A13"],
},
};
```
## API Reference
### `createSeatConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a `BookingSystemConfig` based on `createBaseConfig` with seat-specific overrides applied first, then user `overrides` applied last (so callers always win).
### `SeatBookableMeta`
`{ seatNumber, rowLabel, sectionLabel, tier, isAccessible, hasObstructedView, adjacentSeatIds }`.
## Implementation Notes
- `conflictRef.mode = "exact_match"` because two bookings only conflict for the same seat if they share start AND end times.
- `supportsLayout: true` flags this domain as renderable via the layout system; downstream visualisations (seat maps) consume this hint.
- `adjacentSeatIds` enables group-booking adjacency requirements; the engine itself does not enforce adjacency but consumers can read this field to drive UI.
- The factory spreads user `overrides` LAST, so a caller asking for `enableWaitlist: true` wins over the seat-preset default.

View File

@@ -0,0 +1,74 @@
# shared
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createBaseConfig(tenantId, tenantName)` — the canonical default `BookingSystemConfig` factory that every domain preset spreads over and overrides. Centralising defaults here means the five status transitions, the holds policy, the availability cache window, the pricing rounding, the locale defaults, and the empty rules/conditions/price-catalogue arrays all stay in one place. Domain presets then override only the fields that matter for their domain (typically `engineRef.domainPreset`, `conflictRef.mode`, and `bookableTypeRegistry`).
## Features
- Single base factory returning a fully-populated `BookingSystemConfig`.
- Five default status transitions covering pending → confirmed → checked_in → completed plus cancelled/no_show.
- Sensible-by-default holds (enabled, 10-min default duration, 30-min cap).
- Pricing defaults to USD with exclusive tax and 2-decimal rounding.
- Availability cache enabled with 60s TTL and 30s refresh.
## Usage: Basic
```ts
import { createBaseConfig } from "@suaveplan/booking";
const cfg = createBaseConfig("tenant-1", "Example Co.");
```
## Usage: Advanced
```ts
import { createBaseConfig } from "@suaveplan/booking";
import type { BookingSystemConfig } from "@suaveplan/booking";
const cfg: BookingSystemConfig = {
...createBaseConfig("clinic-1", "Heartbeat Clinic"),
engineRef: {
...createBaseConfig("clinic-1", "Heartbeat Clinic").engineRef,
enableWaitlist: true,
maxBookingsPerCustomer: 3,
},
pricingRef: {
defaultCurrency: "EUR",
supportedCurrencies: ["EUR", "USD"],
taxStrategy: "inclusive",
roundingMode: "round",
roundingPrecision: 2,
},
};
```
## API Reference
### `createBaseConfig(tenantId: string, tenantName: string): BookingSystemConfig`
Returns a fresh `BookingSystemConfig` with:
- `schemaVersion: "1.0.0"`
- `tenantRef: { tenantId, tenantName, multiTenant: false, isolationLevel: "row" }`
- `engineRef.domainPreset: "custom"`, `enableHolds: true`, `maxAdvanceBookingDays: 90`
- `availabilityRef`: `{ refreshIntervalMs: 30000, cacheEnabled: true, cacheTtlMs: 60000 }`
- `conflictRef`: `range_overlap` with no buffer, no boundary turnover
- `pricingRef`: USD, exclusive tax, round, precision 2
- `holdRef`: enabled, 10-min default, 30-min cap, auto-release
- `waitlistRef`: disabled by default
- `notificationRef`: disabled by default
- Empty `rules`, `conditions`, all price arrays, `bookableTypeRegistry`
- `statusTransitions`: the five canonical transitions
- `layoutRef: null`, en-US locale, 12h time, MM/dd/yyyy date
## Implementation Notes
- The factory returns a fresh object on each call — safe to spread into preset configs without sharing references.
- The status transitions are JSON-safe; consumers can serialise the entire config tree losslessly.
- The `auto_transition_after: "PT24H"` on the `confirmed → no_show` transition is metadata; the engine does not schedule the firing itself.
- Presets override fields by spreading the base then assigning, so adding a new field here propagates to every preset without changes.

View File

@@ -0,0 +1,79 @@
# transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the transport booking domain — flights, trains, buses. Each bookable represents a single transport-seat on a specific vehicle and route at a specific departure time. The preset configures `conflictRef.mode = "exact_match"` (a seat is booked iff that exact seat+leg is already taken), `engineRef.enableWaitlist: true` (overbooked transports run waitlists routinely), and a `bookableTypeRegistry` entry with required (`vehicleId`, `routeId`, `classId`) and optional (`seatNumber`, `legIndex`) attributes.
## Features
- `createTransportConfig(tenantId, tenantName, overrides?)` — preset factory.
- `TransportBookableMeta` — typed attribute bag (extends with `departureStationId`, `arrivalStationId`).
- Defaults `pricingModel` to `"fixed"`, `conflictMode` to `"exact_match"`, `supportsLayout: true`.
- 15-minute hold default; longer than other presets because transport checkout flows take longer.
## Usage: Basic
```ts
import { createTransportConfig } from "@suaveplan/booking";
const cfg = createTransportConfig("airline-1", "Albatross Airways");
```
## Usage: Advanced
```ts
import { createTransportConfig } from "@suaveplan/booking";
import type { Bookable, TransportBookableMeta } from "@suaveplan/booking";
const cfg = createTransportConfig("airline-1", "Albatross Airways", {
pricingRef: {
defaultCurrency: "USD",
supportedCurrencies: ["USD", "EUR", "GBP", "JPY"],
taxStrategy: "exclusive",
roundingMode: "round",
roundingPrecision: 2,
},
});
const seat24A: Bookable<TransportBookableMeta> = {
identifierRef: { id: "flight-ABC123-24A", tenantId: "airline-1", categoryId: "domestic" },
name: "Flight ABC123 - 24A",
description: "Window seat, economy",
bookableType: "transport-seat",
status: "active",
layoutNodeRef: "node-seat-24A",
capacityRef: "cap-flight-ABC123",
priceRuleRefs: ["rate-economy"],
timeSlotRefs: [],
attributes: {
vehicleId: "B737-MAX-001",
routeId: "JFK-LHR-2026-06-15",
classId: "economy",
seatNumber: "24A",
departureStationId: "JFK",
arrivalStationId: "LHR",
legIndex: 0,
},
};
```
## API Reference
### `createTransportConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a config with transport-specific defaults (exact_match mode, fixed pricing, waitlist enabled).
### `TransportBookableMeta`
`{ vehicleId, routeId, classId, seatNumber, departureStationId, arrivalStationId, legIndex }`. `seatNumber` is nullable for unassigned-seating modes.
## Implementation Notes
- `exact_match` mode is correct for seat-assigned transport — two bookings only conflict when the same `bookableRef` covers the same window.
- `legIndex` enables multi-leg itineraries where one ticket covers several segments; consumers compose multiple bookables when needed.
- `supportsLayout: true` enables seat-map renderers (similar to the seats domain) so airlines can offer visual seat selection.
- `enableWaitlist: true` accommodates standby passenger queues that transport operators routinely run.

View File

@@ -0,0 +1,94 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and typed `BookingError` class for `@suaveplan/booking`. Every failure path the package exposes raises a `BookingError` carrying one of the seven codes registered here (`ENGINE_NOT_CONFIGURED`, `BOOKING_NOT_FOUND`, `BOOKING_CONFLICT`, `TRANSITION_NOT_ALLOWED`, `UNKNOWN_CONFLICT_MODE`, `UNKNOWN_AVAILABILITY_STRATEGY`, `HOOK_RETURNED_PROMISE_IN_SYNC`). Codes are namespaced strings (`"BOOKING.BOOKING_CONFLICT"`) so consumers can programmatically dispatch without parsing messages, and the typed error narrows discriminants (`bookingId`, `holdId`, `mode`, `strategy`) for diagnostics.
## Features
- `BOOKING_CODES` — `defineCodes("BOOKING", [...])` registry; the canonical source of truth.
- `BookingError extends SuaveplanError<BookingErrorCode>` — typed error class with optional discriminants.
- `BookingErrorCode` — union of every registered code.
- `BookingErrorOptions` — Zod-friendly options bag extending `SuaveplanErrorOptions`.
- Discriminants flow through to OTel span events via `SuaveplanError.toSpanEvent()`.
## Usage: Basic
```ts
import { BOOKING_CODES, BookingError } from "@suaveplan/booking";
function getBooking(id: string) {
const stored = lookup(id);
if (!stored) {
throw new BookingError(
BOOKING_CODES.BOOKING_NOT_FOUND,
`Booking '${id}' not found`,
{ bookingId: id }
);
}
return stored;
}
```
## Usage: Advanced
```ts
import { BOOKING_CODES, BookingError } from "@suaveplan/booking";
try {
engine.confirmBooking(req);
} catch (err) {
if (err instanceof BookingError) {
switch (err.code) {
case BOOKING_CODES.ENGINE_NOT_CONFIGURED:
return reconfigureAndRetry();
case BOOKING_CODES.BOOKING_CONFLICT:
return suggestAlternativeSlot(err.bookingId);
case BOOKING_CODES.TRANSITION_NOT_ALLOWED:
return promptForReason(err.bookingId);
default:
throw err;
}
}
throw err;
}
```
## API Reference
### `BOOKING_CODES`
A frozen registry created via `defineCodes("BOOKING", [...] as const)`. Each entry maps the bare key (e.g. `ENGINE_NOT_CONFIGURED`) to its namespaced string (`"BOOKING.ENGINE_NOT_CONFIGURED"`).
### `BookingErrorCode`
Union type `(typeof BOOKING_CODES)[keyof typeof BOOKING_CODES]`. Use to type-check `error.code` in catch blocks.
### `BookingError(code, message, options?)`
Constructor parameters:
- `code: BookingErrorCode` — required.
- `message: string` — required, English diagnostic.
- `options.bookingId?: string` — attaches `error.bookingId`.
- `options.holdId?: string` — attaches `error.holdId`.
- `options.mode?: string` — attaches `error.mode` (conflict-mode).
- `options.strategy?: string` — attaches `error.strategy` (availability strategy id).
- All `SuaveplanErrorOptions` fields (cause, context, retriable, etc.) are forwarded to the base.
Throws nothing. Construction never fails.
### `BookingErrorOptions`
Interface extending `SuaveplanErrorOptions` with the four optional discriminants above.
## Implementation Notes
- `BOOKING_CODES` is declared `as const` so TypeScript can infer the union from the literal tuple — adding a new code requires updating callers because the union widens.
- Discriminants are assigned conditionally (only when defined) so `JSON.stringify(error)` does not emit `"bookingId": undefined`.
- The class extends `SuaveplanError<BookingErrorCode>` with the generic threaded so `instanceof BookingError` narrows `code` to the union.
- Sole consumer rule: every `throw` in `src/` that surfaces to the public API must construct a `BookingError`. The conformance test and gate 6.3 enforce that every code is reachable from a production path.

View File

@@ -0,0 +1,94 @@
# hooks
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-package data-transform pipeline used by `BookingEngine` to give consumers a hook into the create-booking and cancel-booking lifecycle. Each registered handler receives the data flowing through the chain plus a `HookContext` and can transform, abort, or pass through. The implementation replaces the upstream `@suaveplan/events/hooks` dependency (not yet exposed ) with a small in-package class whose public surface is preserved verbatim, so consumers can adopt the engine without a separate events subpath migration.
## Features
- Priority-sorted handler chain (higher priority runs first).
- Synchronous (`applyHooks`) and asynchronous (`applyHooksAsync`) entry points.
- Detailed reporting via `applyHooksWithResult` (`{ data, aborted, handlersRun, labels }`).
- Once-handlers auto-remove after they fire.
- `context.abort()` short-circuits the chain.
- Typed via `Hooks<TMap extends HookMap>` so each hook name pins a payload type.
- Sync entry point throws `BookingError({ code: BOOKING.HOOK_RETURNED_PROMISE_IN_SYNC })` if a handler returns a thenable.
## Usage: Basic
```ts
import { Hooks } from "@suaveplan/booking";
interface Map { "order:processing": { items: string[]; total: number } }
const hooks = new Hooks<Map>();
hooks.hook("order:processing", (data) => ({ ...data, total: data.total * 1.2 }));
const out = hooks.applyHooks("order:processing", { items: ["a"], total: 100 });
// out.total === 120
```
## Usage: Advanced
```ts
import { Hooks } from "@suaveplan/booking";
interface Map { "user:creating": { name: string; email: string } }
const hooks = new Hooks<Map>();
const unsub = hooks.hook(
"user:creating",
(data, ctx) => {
if (!data.email.includes("@")) ctx.abort();
return { ...data, email: data.email.toLowerCase() };
},
{ priority: 10, label: "normalize-email" }
);
hooks.hook("user:creating", async (data) => {
const ok = await checkBlocklist(data.email);
return ok ? data : { ...data, name: "[blocked]" };
}, { once: true });
const result = await hooks.applyHooksWithResult("user:creating", {
name: "Alice", email: "ALICE@EXAMPLE.COM",
});
// result.handlersRun, result.labels === ["normalize-email"], result.aborted
unsub();
```
## API Reference
### `class Hooks<TMap extends HookMap>`
| Method | Returns | Notes |
|---|---|---|
| `hook(name, handler, options?)` | `() => void` | Register; returns an unsubscribe. Options: `priority`, `once`, `label`. |
| `unhook(name, handler)` | `void` | Remove by handler reference. |
| `unhookAll(name?)` | `void` | Remove all handlers for a hook (or all hooks). |
| `applyHooks(name, data)` | `TMap[K]` | Sync chain; throws on async return. |
| `applyHooksAsync(name, data)` | `Promise<TMap[K]>` | Async chain; awaits each handler. |
| `applyHooksWithResult(name, data)` | `Promise<HookResult<TMap[K]>>` | As above + `{ aborted, handlersRun, labels }`. |
| `hookNames()` | `(keyof TMap)[]` | Currently registered hook names. |
| `hookCount(name)` | `number` | Number of handlers for a hook. |
| `hasHook(name)` | `boolean` | True iff at least one handler is registered. |
### Types
- `HookMap` — index signature `{ [key: string]: unknown }`; extend for typed payloads.
- `HookHandler<T>` — `(data: T, ctx: HookContext) => T | undefined | Promise<T | undefined>`.
- `HookContext` — `{ hook, index, total, abort(), aborted, meta }`.
- `HookHandlerOptions` — `{ priority?, once?, label? }`.
- `HookResult<T>` — `{ data, aborted, handlersRun, labels }`.
- `RegisteredHook<T>` — internal record (`handler`, `priority`, `once`, `label`).
## Implementation Notes
- Storage uses `Map<key, RegisteredHook<unknown>[]>` with per-call narrowing to `HookHandler<TMap[K]>`; no `any` escape hatches.
- Once handlers are collected during iteration and removed in a post-pass so removal mid-chain does not skew the loop index.
- Async return inside `applyHooks` raises `BookingError({ code: BOOKING.HOOK_RETURNED_PROMISE_IN_SYNC })` rather than the upstream `console.warn` — universal libraries do not write to stdout.
- `applyHooksWithResult` is always async even when no handlers are registered (it returns a resolved promise) so consumers can treat it uniformly.
- Aborting only short-circuits the chain; data already transformed by previous handlers is returned.

View File

@@ -0,0 +1,71 @@
# calculator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the booking engine's price-calculation pipeline. `calculatePrice` is the canonical entry point: it runs base → tiers → modifiers → tax → rounding in a single deterministic pass against a `PriceCalculationContext`. The individual steps (`calculateBasePrice`, `applyTiers`, `applyModifiers`, `calculateTax`) are also exported so consumers can compose alternative pipelines or pre-compute partial values. Rounding honours `round` / `floor` / `ceil` modes at configurable precision.
## Features
- Four pricing models — `fixed`, `per_unit`, `per_duration`, `tiered` (plus permissive fallback).
- Quantity tiers via `applyTiers(baseAmount, quantity, tiers, ruleRefs)`.
- Four modifier types — `discount`, `surcharge`, `fee`, `tax`.
- Tax strategy: `exclusive` (10% default) or `inclusive` (no add-on tax when no tax modifier exists).
- Three rounding modes: `round`, `floor`, `ceil`.
- `BookingPrice.breakdown` carries the audit trail (`[...ruleIds, ...modifierIds]`).
## Usage: Basic
```ts
import { calculatePrice } from "@suaveplan/booking";
import type { PriceCalculationContext } from "@suaveplan/booking/pricing/calculator/calculator";
const ctx: PriceCalculationContext = {
bookableRef: "room-101",
windowRef: { startTime: "2026-06-01T14:00:00Z", endTime: "2026-06-03T11:00:00Z", timezone: "UTC" },
quantity: 1,
rules: [{ ruleId: "nightly", amount: 150, currency: "USD", pricingModel: "per_duration", unitDuration: "P1D" }],
tiers: [], modifiers: [], currency: "USD",
taxStrategy: "exclusive", roundingMode: "round", roundingPrecision: 2,
};
const price = calculatePrice(ctx);
// price.subtotal === 300, price.tax === 30, price.total === 330
```
## Usage: Advanced
```ts
import { applyModifiers, applyTiers, calculateBasePrice, calculateTax } from "@suaveplan/booking";
const base = calculateBasePrice(rules, 7, windowRef);
const tiered = applyTiers(base, 7, tiers, rules.map((r) => r.ruleId));
const { adjusted, fees, discount, applied } = applyModifiers(tiered, modifiers);
const tax = calculateTax(adjusted, "exclusive", modifiers);
console.log({ subtotal: adjusted, tax, fees, total: adjusted + tax + fees, discount, applied });
```
## API Reference
| Function | Returns | Notes |
|---|---|---|
| `calculateBasePrice(rules, quantity, windowRef)` | `number` | Handles `fixed` / `per_unit` / `per_duration` / `tiered` / default. |
| `applyTiers(baseAmount, quantity, tiers, ruleRefs)` | `number` | Picks the bracket whose `[minQuantity, maxQuantity]` contains `quantity`. |
| `applyModifiers(subtotal, modifiers)` | `ApplyModifiersResult` | `{ adjusted, fees, discount, applied }`. |
| `calculateTax(subtotal, taxStrategy, modifiers)` | `number` | Tax modifiers win; otherwise 10% on `exclusive`, 0 on `inclusive`. |
| `calculatePrice(context)` | `BookingPrice` | Runs the full pipeline with rounding. |
### `ApplyModifiersResult`
`{ adjusted, fees, discount, applied: string[] }`. `applied` lists every modifier id traversed.
## Implementation Notes
- `per_duration` falls back to a 1-day unit when `unitDuration` parses to `0` so missing data does not produce divide-by-zero.
- Tier brackets are sorted ascending by `minQuantity`; the first matching bracket wins. If no bracket matches, the un-tiered `baseAmount` is returned unchanged.
- `applyModifiers` walks modifiers in order; `discount` reduces the running subtotal AND increments the audit `discount` field, `surcharge` increases it, `fee` accumulates separately, `tax` is skipped here and accounted for in `calculateTax`.
- Rounding is applied to every component of `BookingPrice` independently so the breakdown stays consistent with the displayed total.
- `breakdown` concatenates rule ids first then modifier ids — preserving the order matters for downstream receipt rendering.

View File

@@ -0,0 +1,68 @@
# dynamic
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements dynamic-pricing rule evaluation. Three trigger types are supported — `occupancy_threshold`, `advance_days`, and `demand_score` — and matching rules compose multiplicatively against a base amount. Used by the engine (or by consumers composing custom pipelines) to model surge pricing during peak occupancy, last-minute discounts based on lead time, and demand-driven adjustments derived from external signals.
## Features
- Three trigger types (extensible via custom rules with `triggerType: "..."` and a custom evaluator if needed).
- `findApplicableDynamicRules(rules, context)` — every matching rule.
- `calculateDynamicMultiplier(rules, context)` — product of matching multipliers (1.0 when none match).
- `applyDynamicPricing(baseAmount, rules, context)` — convenience composition.
- `createDynamicContext(...)` — helper for building a `DynamicPricingContext` with default demand.
## Usage: Basic
```ts
import { applyDynamicPricing, createDynamicContext } from "@suaveplan/booking";
const rules = [
{ ruleId: "surge", priceRuleRef: "p", triggerType: "occupancy_threshold", threshold: 0.8, multiplier: 1.5 },
];
const ctx = createDynamicContext(0.9, 30);
applyDynamicPricing(100, rules, ctx); // 150
```
## Usage: Advanced
```ts
import {
calculateDynamicMultiplier, findApplicableDynamicRules,
} from "@suaveplan/booking";
const rules = [
{ ruleId: "surge", priceRuleRef: "p", triggerType: "occupancy_threshold", threshold: 0.85, multiplier: 1.3 },
{ ruleId: "lastmin", priceRuleRef: "p", triggerType: "advance_days", threshold: 3, multiplier: 0.85 },
{ ruleId: "viral", priceRuleRef: "p", triggerType: "demand_score", threshold: 80, multiplier: 1.2 },
];
const ctx = { occupancyRate: 0.9, advanceDays: 2, demandScore: 90 };
const matched = findApplicableDynamicRules(rules, ctx);
const m = calculateDynamicMultiplier(rules, ctx); // 1.3 * 0.85 * 1.2 ≈ 1.326
console.log(`Matched ${matched.length} rules, total multiplier: ${m.toFixed(3)}`);
```
## API Reference
| Function | Returns | Notes |
|---|---|---|
| `findApplicableDynamicRules(rules, context)` | `DynamicPricingRule[]` | Unknown `triggerType` evaluates `false`. |
| `calculateDynamicMultiplier(rules, context)` | `number` | 1.0 on no match. |
| `applyDynamicPricing(base, rules, context)` | `number` | base × multiplier. |
| `createDynamicContext(occupancyRate, advanceDays, demandScore = 0)` | `DynamicPricingContext` | Convenience constructor. |
### `DynamicPricingContext`
`{ occupancyRate, advanceDays, demandScore }` — three named axes the rules dispatch on.
## Implementation Notes
- Triggers use different comparison directions: `occupancy_threshold` and `demand_score` use `>=`, `advance_days` uses `<=` (last-minute discounts kick in BELOW the threshold).
- Unknown `triggerType` values evaluate `false` so a typo cannot silently amplify prices. Pair with the Zod schema to validate input.
- The multiplier composition is purely multiplicative; rules' priorities are not honoured here (the engine treats them all as independent).
- `createDynamicContext` defaults `demandScore` to `0` so consumers with no demand-tracking still get a well-typed context.

View File

@@ -0,0 +1,64 @@
# seasonal
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Applies seasonal-rate multipliers to a base price based on month/day windows. Supports wrap-around-year intervals (e.g. December 1 → February 28) for ski-season-style pricing. Used by the engine when a config carries `seasonalRates[]` and the booking window's calendar dates fall inside one or more configured seasons. Multiple matching seasons compose multiplicatively.
## Features
- `findApplicableRates(rates, windowRef)` — every season whose window overlaps the booking dates.
- Wrap-around-year handling via disjunctive overlap test.
- `calculateSeasonalMultiplier(rates)` — product of every applicable rate's multiplier.
- `applySeasonalRate(baseAmount, rates, windowRef)` — convenience over the above two.
- `getSeasonName(rates, windowRef)` — name of the first applicable season (or `null`).
## Usage: Basic
```ts
import { applySeasonalRate } from "@suaveplan/booking";
const summer = { rateId: "s", priceRuleRef: "p", seasonName: "Summer",
startMonth: 6, startDay: 1, endMonth: 8, endDay: 31, multiplier: 1.5 };
applySeasonalRate(100, [summer], {
startTime: "2026-07-15T14:00:00Z", endTime: "2026-07-20T11:00:00Z", timezone: "UTC",
}); // 150
```
## Usage: Advanced
```ts
import {
calculateSeasonalMultiplier, findApplicableRates, getSeasonName,
} from "@suaveplan/booking";
const winter = { rateId: "w", priceRuleRef: "p", seasonName: "Winter",
startMonth: 12, startDay: 1, endMonth: 2, endDay: 28, multiplier: 1.4 };
const peak = { rateId: "px", priceRuleRef: "p", seasonName: "Peak Week",
startMonth: 12, startDay: 24, endMonth: 12, endDay: 31, multiplier: 1.2 };
const window = { startTime: "2026-12-26T14:00:00Z", endTime: "2026-12-30T11:00:00Z", timezone: "UTC" };
const applicable = findApplicableRates([winter, peak], window);
calculateSeasonalMultiplier(applicable); // 1.4 * 1.2 = 1.68
getSeasonName([winter, peak], window); // "Winter" (first applicable)
```
## API Reference
| Function | Returns | Notes |
|---|---|---|
| `findApplicableRates(rates, windowRef)` | `SeasonalRate[]` | Empty array if none match. |
| `calculateSeasonalMultiplier(rates)` | `number` | Returns 1.0 for empty input. |
| `applySeasonalRate(baseAmount, rates, windowRef)` | `number` | Composes the two above. |
| `getSeasonName(rates, windowRef)` | `string \| null` | First applicable season's name. |
## Implementation Notes
- Season windows are evaluated in the booking window's start year — long bookings spanning a year boundary use the start year's calendar.
- Wrap-around test: when `seasonStart > seasonEnd` (December → February), the booking matches iff it's before `seasonEnd` OR after `seasonStart` — the disjunction correctly captures the wrap.
- `getSeasonName` returns `null` (not `""`) when no season matches so callers can branch on truthiness without ambiguity.
- All arithmetic happens via `Date.UTC` to avoid local-timezone surprises.

View File

@@ -0,0 +1,71 @@
# conditions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pre-built `RuleCondition` factories for the most common booking constraints — minimum/maximum duration, advance-booking lead time, quantity range, custom attribute matching, weekend detection, status matching, and per-customer booking limits. Each factory returns a fully-formed `RuleCondition` referencing the right field path (`window.durationHours`, `booking.status`, `customer.bookingCount`, etc.) and the right operator. The factories save consumers from hand-rolling identical conditions across rules and ensure the field paths stay consistent with the evaluator's `resolveField` dispatch.
## Features
- Eight factories covering duration / advance / quantity / attribute / weekend / status / customer-limit / max-duration constraints.
- Pure functions — call once, store the returned `RuleCondition` in `BookingSystemConfig.conditions`.
- Reference by id from any `BookingRule.conditionRefs` array.
## Usage: Basic
```ts
import { createMinDurationCondition, createWeekendCondition } from "@suaveplan/booking";
const conditions = [
createMinDurationCondition("c-min-1h", 1),
createWeekendCondition("c-weekend"),
];
```
## Usage: Advanced
```ts
import {
createAdvanceBookingCondition, createAttributeCondition,
createCustomerLimitCondition, createMaxDurationCondition,
createQuantityRangeCondition, createStatusCondition,
} from "@suaveplan/booking";
const conditions = [
createMaxDurationCondition("c-max-24h", 24),
createAdvanceBookingCondition("c-need-2h", 2),
createQuantityRangeCondition("c-q-1-10", 1, 10),
createAttributeCondition("c-vip-only", "tier", "eq", "vip"),
createStatusCondition("c-confirmed", "confirmed"),
createCustomerLimitCondition("c-loyalty", 10),
];
const rule = {
ruleId: "vip-perk", name: "VIP loyalty perk", priority: 1, enabled: true,
conditionRefs: ["c-vip-only", "c-loyalty"], conditionLogic: "all",
actionRef: { actionType: "apply_modifier", message: "VIP 10% off", modifierRef: "m-vip-10" },
};
```
## API Reference
| Factory | Field | Operator | Value |
|---|---|---|---|
| `createMinDurationCondition(id, h)` | `window.durationHours` | `lt` | `h` (triggers if duration < h) |
| `createMaxDurationCondition(id, h)` | `window.durationHours` | `gt` | `h` (triggers if duration > h) |
| `createAdvanceBookingCondition(id, h)` | `window.advanceHours` | `lt` | `h` (triggers if advance < h) |
| `createQuantityRangeCondition(id, min, max)` | `booking.quantity` | `between` | `[min, max]` |
| `createAttributeCondition(id, path, op, val)` | `bookable.attributes.<path>` | `op` | `val` |
| `createWeekendCondition(id)` | `window.isWeekend` | `eq` | `true` |
| `createStatusCondition(id, status)` | `booking.status` | `eq` | `status` |
| `createCustomerLimitCondition(id, max)` | `customer.bookingCount` | `gte` | `max` |
## Implementation Notes
- Field paths assume the consumer's `EvaluationContext` populates the dotted keys correctly — the engine itself does not currently derive `window.durationHours` or `window.advanceHours`; consumers compute these before invoking the evaluator (or wire a hook).
- The "minimum" and "maximum" duration conditions use `lt` / `gt` semantics because they are TRUE when the BAD case holds (so the rule's `deny` action fires).
- `createAttributeCondition` takes the operator string verbatim so consumers can use any of the 11 supported operators.
- Every factory returns a fresh object on each call; safe to call inside a loop without sharing references.

View File

@@ -0,0 +1,98 @@
# evaluator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The rules-engine evaluator walks a list of `BookingRule[]` against an `EvaluationContext` (booking + bookable + window + customer) and computes a single `RuleEvaluationResult` carrying every failure, every applied modifier, and every user-facing message. `evaluateRule(rule, conditions, context)` is the per-rule predicate; `evaluateRules(rules, conditions, context)` is the orchestrator that sorts by priority, walks every enabled rule, and aggregates outcomes. Supports 11 operators and two logic modes (`all`, `any`).
## Features
- Path-addressed field resolution (`booking.quantity`, `bookable.attributes.tier`, `window.durationHours`, `customer.bookingCount`).
- 11 operators — `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `between`, `regex`, plus a permissive default returning `false`.
- Logic modes — `all` (default; every condition must pass) and `any`.
- Two action types — `deny` (records failed rule) and `apply_modifier` (records modifier id).
- Priority-sorted execution (lower number = earlier).
- Disabled rules are skipped completely.
## Usage: Basic
```ts
import { evaluateRules } from "@suaveplan/booking";
const rules = [{
ruleId: "no-short", name: "Minimum 2h", priority: 1, enabled: true,
conditionRefs: ["c-min"], conditionLogic: "all",
actionRef: { actionType: "deny", message: "Booking must be ≥2 hours", modifierRef: null },
}];
const conditions = [{ conditionId: "c-min", field: "window.durationHours", operator: "lt", value: 2 }];
const result = evaluateRules(rules, conditions, {
booking: { quantity: 1 },
bookable: {}, window: { durationHours: 1.5 }, customer: {},
});
// result.passed === false; result.messages[0] === "Booking must be ≥2 hours"
```
## Usage: Advanced
```ts
import { evaluateRule, evaluateRules } from "@suaveplan/booking";
import type { EvaluationContext } from "@suaveplan/booking/rules/evaluator/evaluator";
const ctx: EvaluationContext = {
booking: { status: "pending", quantity: 5 },
bookable: { attributes: { tier: "vip" } },
window: { durationHours: 4, isWeekend: true },
customer: { bookingCount: 12 },
};
const conditions = [
{ conditionId: "c-vip", field: "bookable.attributes.tier", operator: "eq", value: "vip" },
{ conditionId: "c-loyalty", field: "customer.bookingCount", operator: "gte", value: 10 },
{ conditionId: "c-quantity", field: "booking.quantity", operator: "between", value: [1, 10] },
];
const weekendSurcharge = {
ruleId: "surch-weekend", name: "Weekend +15%", priority: 5, enabled: true,
conditionRefs: ["c-vip", "c-loyalty", "c-quantity"], conditionLogic: "all" as const,
actionRef: { actionType: "apply_modifier", message: "Weekend surcharge", modifierRef: "m-weekend-15" },
};
const result = evaluateRules([weekendSurcharge], conditions, ctx);
console.log(result.appliedModifiers); // ["m-weekend-15"]
console.log(evaluateRule(weekendSurcharge, conditions, ctx)); // true
```
## API Reference
### `evaluateRule(rule, conditions, context): boolean`
Returns `true` iff the rule's gated conditions match (or it has no conditions). Disabled rules return `true` unconditionally (so they don't block downstream rules).
### `evaluateRules(rules, conditions, context): RuleEvaluationResult`
Walks rules sorted ascending by `priority`. For each matching enabled rule:
- `deny` → push to `failedRules`, push message to `messages`.
- `apply_modifier` (with non-empty `modifierRef`) → push `modifierRef` to `appliedModifiers`, push message.
Returns `{ passed: failedRules.length === 0, failedRules, appliedModifiers, messages }`.
### `EvaluationContext`
`{ booking, bookable, window, customer }`. Each is `Record<string, unknown>`; the evaluator walks paths via `getNestedValue`.
### `ConditionEvaluatorFn`
`(fieldValue, operator, compareValue) => boolean`. The internal evaluator type, useful for extending with custom operators.
## Implementation Notes
- Field paths without a recognised root (`booking`/`bookable`/`window`/`customer`) fall back to `context.booking`, matching upstream behaviour.
- `gt`/`gte`/`lt`/`lte`/`between` coerce both operands to numbers via `Number(...)`; non-numeric inputs become `NaN` and fail the comparison.
- `regex` requires both `fieldValue` and `compareValue` to be strings; otherwise returns `false`.
- Unknown `operator` values fall to the `default: false` branch — the evaluator never throws, so a typo cannot crash an engine in production.
- Priority sorts ASCENDING; rule `priority: 0` runs first.

View File

@@ -0,0 +1,71 @@
# booking.schema
## Purpose
Zod validation schemas covering every entity at the booking engine's input boundary — `Bookable`, `Booking`, `BookingRequest`, `HoldRequest`, `TimeWindow`, plus the sub-types `BookableId`, `BookingId`, `BookingPrice`, and `CancellationTerms`. Each schema mirrors the corresponding `.types.ts` shape; consumers parse incoming JSON through these at the network/RPC boundary to catch malformed input before it reaches the engine. Three convenience validators (`validateBookable`, `validateBooking`, `validateBookingRequest`) provide throw-on-invalid entry points.
## Features
- `timeWindowSchema` — datetime + non-empty timezone.
- `bookableSchema` / `bookingSchema` — full entity validation.
- `bookingRequestSchema` / `holdRequestSchema` — mutation input validation.
- `validateBookable` / `validateBooking` / `validateBookingRequest` — throw-on-invalid helpers returning the parsed (typed) data.
- Schemas compose — `bookingSchema` references `bookingIdSchema`, `timeWindowSchema`, `bookingPriceSchema`, `cancellationTermsSchema`.
## Usage: Basic
```ts
import { validateBookingRequest } from "@suaveplan/booking";
const req = validateBookingRequest(JSON.parse(rawBody));
engine.confirmBooking(req);
```
## Usage: Advanced
```ts
import {
bookableSchema, bookingRequestSchema, bookingSchema,
} from "@suaveplan/booking";
const partialBookable = bookableSchema.partial();
const parseResult = bookingRequestSchema.safeParse(input);
if (!parseResult.success) {
return Response.json({ error: parseResult.error.flatten() }, { status: 400 });
}
const req = parseResult.data;
const bookingsParse = bookingSchema.array().safeParse(history);
if (bookingsParse.success) {
const bookings = bookingsParse.data;
console.log(`Loaded ${bookings.length} valid bookings`);
}
```
## API Reference
| Schema | Validates |
|---|---|
| `timeWindowSchema` | `{ startTime, endTime, timezone }` (datetimes + non-empty tz) |
| `bookableIdSchema` | `{ id, tenantId, categoryId }` |
| `bookingIdSchema` | `{ id, tenantId, createdAt }` |
| `bookingPriceSchema` | Non-negative subtotal/tax/fees/discount, 3-char currency. |
| `cancellationTermsSchema` | `{ refundable, deadlineBeforeStart, penaltyCalculation, penaltyValue }` |
| `bookableSchema` | Full `Bookable` shape (nullable `layoutNodeRef`). |
| `bookingSchema` | Full `Booking` shape; `quantity >= 1`. |
| `holdRequestSchema` | Hold input. |
| `bookingRequestSchema` | Booking input (optional `notes`/`metadata`/`holdId`). |
| Helper | Returns | On error |
|---|---|---|
| `validateBookable(data)` | parsed `Bookable` | Zod throws. |
| `validateBooking(data)` | parsed `Booking` | Zod throws. |
| `validateBookingRequest(data)` | parsed `BookingRequest` | Zod throws. |
## Implementation Notes
- Currency codes are checked for length 3 only; consumers wanting ISO-4217 enforcement compose `z.literal("USD").or(...)` separately.
- `metadata` is `z.record(z.string(), z.unknown())` — consumers who want typed metadata can `.refine()` or substitute their own.
- `validate*` helpers `throw` so they fit a request-handler `try/catch` cleanly; consumers wanting non-throwing parsing call `bookableSchema.safeParse(...)` directly.
- `quantity` is bounded `>= 1` to match upstream and prevent zero-quantity bookings reaching the engine.

View File

@@ -0,0 +1,69 @@
# config.schema
## Purpose
Validates the full `BookingSystemConfig` JSON tree at runtime. The schema mirrors the `config.types.ts` shape and is the right entry point for loading a config from disk or the network: invalid input fails fast with a structured Zod error rather than producing weird runtime behaviour deep inside the engine. Provides both throwing (`validateConfig`) and non-throwing (`safeValidateConfig`) entry points so consumers pick the right error-handling style.
## Features
- `bookingSystemConfigSchema` — the root config schema.
- Sub-schemas for every nested shape (`tenantConfigSchema`, `engineConfigSchema`, `holdConfigSchema`, etc.).
- `validateConfig(data)` — throws on invalid.
- `safeValidateConfig(data)` — returns `{ success, data | error }` discriminated.
## Usage: Basic
```ts
import { validateConfig } from "@suaveplan/booking";
const cfg = validateConfig(JSON.parse(rawJson));
engine.configure(cfg);
```
## Usage: Advanced
```ts
import { safeValidateConfig } from "@suaveplan/booking";
const parseResult = safeValidateConfig(JSON.parse(rawJson));
if (!parseResult.success) {
console.error("Invalid booking config:", parseResult.error.flatten());
process.exit(1);
}
const cfg = parseResult.data;
// Schema reuse: validate a partial update against a subset
import { engineConfigSchema } from "@suaveplan/booking/schemas/config.schema";
const engineSchema = engineConfigSchema.partial();
const update = engineSchema.parse({ maxBookingsPerCustomer: 10 });
```
## API Reference
| Schema | Notes |
|---|---|
| `bookingSystemConfigSchema` | Root; references every sub-schema. |
| `tenantConfigSchema` | Tenant id + name + multitenant flag + isolation level. |
| `engineConfigSchema` | Domain preset + custom strategies + feature flags + limits. |
| `holdConfigSchema` | Hold defaults; `defaultDurationMinutes >= 1`. |
| `waitlistConfigSchema` | Waitlist policy. |
| `pricingEngineConfigSchema` | 3-char currency, taxStrategy enum, roundingMode enum, precision 0..10. |
| `availabilityConfigSchema` | Cache + refresh policy. |
| `conflictDetectorConfigSchema` | Mode + buffer + capacity. |
| `localeConfigSchema` | Locale + date/time format + firstDayOfWeek 0..6. |
| `statusTransitionSchema` | One transition row. |
| `attributeDefinitionSchema` | Attribute spec inside a `BookableTypeDefinition`. |
| `bookableTypeDefinitionSchema` | One bookable-type registry entry. |
| `strategyRegistrationSchema` | Custom strategy registration row. |
| Helper | Returns | On error |
|---|---|---|
| `validateConfig(data)` | parsed `BookingSystemConfig` | Zod throws. |
| `safeValidateConfig(data)` | `{ success, data \| error }` | Returns failure result. |
## Implementation Notes
- The schema validates SHAPE; it does NOT cross-check refs (e.g. that `bookableTypeRegistry[i].defaultConflictMode` matches a registered detector). Cross-checking is a higher-level concern.
- `rules`, `conditions`, `priceRules`, `priceTiers`, `priceModifiers`, `seasonalRates`, `dynamicPricingRules`, `layoutRef` are typed as `z.unknown()` (or `.nullable()` for layout) because validating their internal shape requires the corresponding rules/pricing-types schemas. Callers compose those validations as needed.
- `taxStrategy` and `roundingMode` are real enums so a typo (`"flooor"`) fails validation immediately.
- `safeValidateConfig` is the right call from a long-running process; `validateConfig` suits startup / one-shot scripts where throwing is acceptable.

View File

@@ -0,0 +1,77 @@
# recurrence
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Expands `RecurrenceRule` definitions into concrete `TimeWindow[]` occurrences across a date range. Supports daily, weekly, monthly, and yearly cadences with optional `daysOfWeek` filtering, `exceptions` for one-off skips, and a hard 1000-iteration guard so a malformed rule cannot turn into a runaway loop. Used by the engine when consumers ask for available slots over a future range.
## Features
- Four frequency cadences (`daily`, `weekly`, `monthly`, `yearly`) plus a permissive fallback.
- `daysOfWeek` filter for weekly schedules (0=Sun..6=Sat, UTC).
- `exceptions` skip-list keyed by ISO date prefix (YYYY-MM-DD).
- Range narrowing — only occurrences inside `[rangeStart, min(rangeEnd, rule.endDate)]` are emitted.
- Hard `MAX_ITERATIONS = 1000` guard.
- `isRecurring(rule)` and `createRecurrenceRule(...)` helpers.
## Usage: Basic
```ts
import { createRecurrenceRule, expandRecurrence } from "@suaveplan/booking";
const rule = createRecurrenceRule("daily", 1, { endDate: "2026-06-07T00:00:00Z" });
const slots = expandRecurrence(
{ startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T10:00:00Z", timezone: "UTC" },
rule,
"2026-06-01T00:00:00Z",
"2026-06-07T00:00:00Z"
);
// slots.length === 7
```
## Usage: Advanced
```ts
import { createRecurrenceRule, expandRecurrence, isRecurring } from "@suaveplan/booking";
const monWedYoga = createRecurrenceRule("weekly", 1, {
daysOfWeek: [1, 3],
exceptions: ["2026-12-23T00:00:00Z", "2026-12-25T00:00:00Z"],
endDate: "2026-12-31T00:00:00Z",
});
if (isRecurring(monWedYoga)) {
const occurrences = expandRecurrence(
{ startTime: "2026-12-01T18:00:00Z", endTime: "2026-12-01T19:00:00Z", timezone: "UTC" },
monWedYoga,
"2026-12-01T00:00:00Z",
"2026-12-31T23:59:59Z"
);
for (const occ of occurrences) console.log(occ.startTime);
}
```
## API Reference
### `expandRecurrence(baseWindow, rule, rangeStart, rangeEnd): TimeWindow[]`
Emits one occurrence per cadence step that lies inside `[rangeStart, min(rangeEnd, rule.endDate)]`, is not in `rule.exceptions`, and (for weekly rules with non-empty `daysOfWeek`) matches one of the configured days. Each occurrence has the same duration as `baseWindow`.
### `isRecurring(rule: RecurrenceRule | null): boolean`
Returns `true` iff the rule is non-null and its `frequency` is non-empty.
### `createRecurrenceRule(frequency, interval = 1, options?): RecurrenceRule`
Convenience constructor. `options` is a `Partial<RecurrenceRule>` filling in `daysOfWeek`, `endDate`, `exceptions` (each defaults to its empty sentinel).
## Implementation Notes
- The 1000-iteration hard cap protects against pathological inputs (e.g. `frequency: "yearly"` with `interval: 0`). Real-world cadences stay well under it.
- `daysOfWeek` is consulted only when `frequency === "weekly"` AND the list is non-empty; weekly rules with empty `daysOfWeek` emit every weekday-stepped occurrence (the same as upstream).
- `exceptions` are compared by the `toISOString().split("T")[0]` date prefix, so the time-of-day portion of an exception is ignored.
- Unknown `frequency` values fall through to daily advancement so missing data still produces some output (callers can validate via the Zod schema before passing in).
- The function uses UTC throughout — month boundaries, week boundaries, and date keys all resolve in UTC regardless of `baseWindow.timezone`.

View File

@@ -0,0 +1,63 @@
# slots
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Generates and manipulates `TimeSlot` lists. `generateTimeSlots` produces fixed-interval slots covering a single `TimeWindow`; `generateDailySlots` produces slots covering a daily working-hour band across a date range; `filterAvailableSlots` keeps slots with sufficient capacity; `reduceSlotCapacity` is a pure update that floors capacity at zero. Used by the engine when consumers ask "what's available?" over a range or when a booking consumes capacity.
## Features
- Fixed-interval slot generation (`generateTimeSlots`).
- Daily working-hour slot generation across a multi-day range (`generateDailySlots`).
- Capacity filter (`filterAvailableSlots`).
- Pure capacity update floored at zero (`reduceSlotCapacity`).
- Slots carry unique ids via `generateId("slot")`.
## Usage: Basic
```ts
import { generateTimeSlots } from "@suaveplan/booking";
const slots = generateTimeSlots(
{ startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T12:00:00Z", timezone: "UTC" },
30 * 60 * 1000,
10
);
// slots.length === 6, each capacityRemaining === 10
```
## Usage: Advanced
```ts
import {
filterAvailableSlots, generateDailySlots, reduceSlotCapacity,
} from "@suaveplan/booking";
const week = generateDailySlots(
"2026-06-01T00:00:00Z", "2026-06-07T00:00:00Z",
9, 17, 60, "UTC", 5
);
const consumed = week.map((s) => reduceSlotCapacity(s, 3));
const stillBookable = filterAvailableSlots(consumed, 2);
// `consumed` has capacity 2; `stillBookable` keeps every entry.
```
## API Reference
| Function | Signature | Notes |
|---|---|---|
| `generateTimeSlots` | `(window, slotDurationMs, capacity) => TimeSlot[]` | Only emits slots whose end fits inside the window. |
| `generateDailySlots` | `(startDate, endDate, dailyStartHour, dailyEndHour, slotDurationMinutes, timezone, capacity) => TimeSlot[]` | Per-day; hours are 0..23 UTC. |
| `filterAvailableSlots` | `(slots, minCapacity = 1) => TimeSlot[]` | Returns a new array. |
| `reduceSlotCapacity` | `(slot, amount) => TimeSlot` | Returns a new slot object with `capacityRemaining: max(0, …)`. |
## Implementation Notes
- `generateTimeSlots` requires `current + slotDurationMs <= end` — the last slot must fit completely, so no truncated slot is emitted.
- `generateDailySlots` advances day-by-day with `setUTCDate(getUTCDate() + 1)` to stay safe across month boundaries and DST transitions.
- All slot ids come from `generateId("slot")` — they are unique within a single process but not across processes; pair with a persistent id allocator if you need globally unique values.
- `reduceSlotCapacity` is pure: callers must replace the slot in their store rather than mutating in place.

View File

@@ -0,0 +1,79 @@
# windows
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines pure functions for reasoning about `TimeWindow` intervals — overlap, merge, split, containment, adjacency, validity, and duration. Every helper treats windows as half-open `[start, end)` intervals: a booking ending exactly when another starts does NOT overlap, which models the standard "check-out at 11, next guest checks in at 14" hotel semantics correctly. Used by the engine's availability and conflict-detection paths.
## Features
- `windowsOverlap(a, b, allowBoundaryTurnover?)` — half-open overlap; optional flag excludes exact-boundary touches.
- `windowsOverlapWithBuffer(proposed, existing, bufferMs)` — overlap with an extended existing window.
- `mergeWindows(windows)` — collapses adjacent / overlapping windows into the minimal covering set.
- `splitWindow(window, intervalMs)` — fixed-interval slicing.
- `windowDurationMs(window)` — `end - start` in milliseconds.
- `windowContains(outer, inner)` — inclusive containment.
- `createWindow(start, end, timezone?)` — convenience constructor (defaults timezone to UTC).
- `isValidWindow(window)` — `start < end` with valid datetimes.
- `windowsAdjacent(a, b)` — true iff they touch at a boundary.
## Usage: Basic
```ts
import { createWindow, windowsOverlap } from "@suaveplan/booking";
const a = createWindow("2026-06-01T14:00:00Z", "2026-06-03T11:00:00Z");
const b = createWindow("2026-06-03T11:00:00Z", "2026-06-05T11:00:00Z");
windowsOverlap(a, b); // false — half-open at boundary
```
## Usage: Advanced
```ts
import {
mergeWindows, splitWindow, windowsOverlap, windowsOverlapWithBuffer,
} from "@suaveplan/booking";
// 1-hour cleaning buffer on each side
windowsOverlapWithBuffer(proposed, existing, 3_600_000);
// Collapse a fragmented schedule
const free = mergeWindows([
{ startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T10:00:00Z", timezone: "UTC" },
{ startTime: "2026-06-01T10:00:00Z", endTime: "2026-06-01T12:00:00Z", timezone: "UTC" },
]);
// free.length === 1, free[0].endTime === "2026-06-01T12:00:00Z"
// 30-minute slots over a 2-hour window
const slots = splitWindow(
{ startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T11:00:00Z", timezone: "UTC" },
30 * 60 * 1000
);
// slots.length === 4
```
## API Reference
| Function | Signature | Notes |
|---|---|---|
| `windowsOverlap` | `(a, b, allowBoundaryTurnover?: boolean) => boolean` | Half-open. |
| `windowsOverlapWithBuffer` | `(proposed, existing, bufferMs) => boolean` | Buffer expands existing. |
| `mergeWindows` | `(windows: TimeWindow[]) => TimeWindow[]` | Sorts by start; merges contiguous/overlapping. |
| `splitWindow` | `(window, intervalMs) => TimeWindow[]` | Last slot clamped to window end. |
| `windowDurationMs` | `(window) => number` | `end - start`. |
| `windowContains` | `(outer, inner) => boolean` | Closed containment. |
| `createWindow` | `(start, end, timezone = "UTC") => TimeWindow` | Convenience. |
| `isValidWindow` | `(window) => boolean` | Validity + ordering. |
| `windowsAdjacent` | `(a, b) => boolean` | Exact-boundary touch. |
## Implementation Notes
- All arithmetic happens via `Date.parse` (UTC); the `timezone` field on `TimeWindow` is informational. Callers who need wall-clock-correct slicing convert separately.
- `mergeWindows` preserves the timezone of the first window in each merged group.
- `splitWindow` always emits the last slot ending exactly at `window.endTime` even if `intervalMs` does not divide evenly — callers don't get phantom slots past the end.
- The `allowBoundaryTurnover` flag flips the strict half-open semantics on for the hotel-turnover case (where "11am check-out, 2pm check-in" should count as a conflict at the boundary).
- `windowsAdjacent` checks both directions (`aEnd === bStart || bEnd === aStart`) so callers don't need to sort the inputs first.

View File

@@ -0,0 +1,68 @@
# bookable.types
## Purpose
Defines the `Bookable` entity — the thing being booked — plus the query shapes used to look it up. Generic over a `TMeta extends Record<string, unknown>` parameter so each domain (seats, accommodation, appointments, …) can attach its own typed attribute bag (`SeatBookableMeta`, `AccommodationBookableMeta`, …) without losing the rest of the engine's type safety. Identifiers are split into a structured `BookableId` so multi-tenant systems can co-locate bookables from different tenants in a single registry.
## Features
- `BookableId` — `{ id, tenantId, categoryId }` structured identifier.
- `Bookable<TMeta>` — generic entity with `priceRuleRefs`, `timeSlotRefs`, `layoutNodeRef`, and `attributes: TMeta`.
- `BookableQuery` — filterable search (tenant + type + status + category + attributes + paging).
- `BookableQueryResult<TMeta>` — `{ items, total, limit, offset }` paging envelope.
## Usage: Basic
```ts
import type { Bookable } from "@suaveplan/booking";
const room: Bookable = {
identifierRef: { id: "room-101", tenantId: "hotel-1", categoryId: "deluxe" },
name: "Deluxe Suite 101",
description: "Ocean-view king",
bookableType: "hotel-room",
status: "active",
layoutNodeRef: null,
capacityRef: "cap-room-101",
priceRuleRefs: ["rate-standard-night"],
timeSlotRefs: [],
attributes: { roomNumber: "101", floor: 1 },
};
```
## Usage: Advanced
```ts
import type { Bookable, BookableQuery, BookableQueryResult } from "@suaveplan/booking";
import type { SeatBookableMeta } from "@suaveplan/booking/domains/seats/seats";
const query: BookableQuery = {
tenantId: "stadium-x",
bookableType: "seat",
status: "active",
limit: 100,
offset: 0,
};
function pickAccessible(
result: BookableQueryResult<SeatBookableMeta>
): Bookable<SeatBookableMeta>[] {
return result.items.filter((seat) => seat.attributes.isAccessible);
}
```
## API Reference
| Type | Fields |
|---|---|
| `BookableId` | `id`, `tenantId`, `categoryId` |
| `Bookable<TMeta>` | `identifierRef`, `name`, `description`, `bookableType`, `status`, `layoutNodeRef`, `capacityRef`, `priceRuleRefs`, `timeSlotRefs`, `attributes: TMeta` |
| `BookableQuery` | `tenantId`, `bookableType?`, `status?`, `categoryId?`, `attributes?`, `limit?`, `offset?` |
| `BookableQueryResult<TMeta>` | `items: Bookable<TMeta>[]`, `total`, `limit`, `offset` |
## Implementation Notes
- `TMeta extends Record<string, unknown>` defaults to `Record<string, unknown>` so consumers who don't care about domain meta still get a usable type.
- References (`priceRuleRefs`, `timeSlotRefs`, `layoutNodeRef`, `capacityRef`) are string ids, never embedded objects — this keeps `Bookable` JSON-safe and side-tables-friendly.
- `BookableQuery.attributes` is a free-form record; the engine's in-memory `listBookables` ignores it today, but persistent stores may translate it into a `WHERE attributes->...` predicate.
- `BookableQueryResult.total` is the count BEFORE paging, so callers can compute total pages from `total / limit`.

View File

@@ -0,0 +1,73 @@
# booking-hooks.types
## Purpose
Defines the typed hook map and handler aliases consumed by the booking engine's in-package `Hooks<BookingHookMap>` instance. The engine exposes two transform-pipeline points — `booking:creating` (runs before a booking is persisted) and `booking:cancelling` (runs before the state machine evaluates a cancellation) — and consumers register handlers via `engine.bookingHooks.hook(...)`. These types let TypeScript prove that a handler matches the right pipeline at compile time.
## Features
- `BookingCreatingHookData` — full data flowing into the create pipeline (`bookableRef`, `customerRef`, `windowRef`, `quantity`, `notes`, `metadata`).
- `BookingCancellingHookData` — data flowing into the cancel pipeline (`bookingId`, `reason`, `currentStatus`).
- `BookingHookMap extends HookMap` — discriminated map keyed by hook name.
- `BookingCreatingHandler` / `BookingCancellingHandler` — named aliases over `HookHandler<...>`.
## Usage: Basic
```ts
import { createBookingEngine } from "@suaveplan/booking";
import type { BookingCreatingHandler } from "@suaveplan/booking";
const engine = createBookingEngine();
const lowercaseCustomer: BookingCreatingHandler = (data) => ({
...data,
customerRef: data.customerRef.toLowerCase(),
});
engine.bookingHooks.hook("booking:creating", lowercaseCustomer);
```
## Usage: Advanced
```ts
import { createBookingEngine } from "@suaveplan/booking";
import type {
BookingCancellingHandler,
BookingCreatingHandler,
BookingHookMap,
} from "@suaveplan/booking";
const engine = createBookingEngine();
const trim: BookingCreatingHandler = (data) => ({
...data,
notes: data.notes.trim(),
});
const requireReason: BookingCancellingHandler = (data, ctx) => {
if (!data.reason) ctx.abort();
return data;
};
engine.bookingHooks.hook("booking:creating", trim, { priority: 10, label: "trim-notes" });
engine.bookingHooks.hook("booking:cancelling", requireReason);
function listAllHooks(): (keyof BookingHookMap)[] {
return engine.bookingHooks.hookNames();
}
```
## API Reference
| Type | Notes |
|---|---|
| `BookingCreatingHookData` | `bookableRef`, `customerRef`, `windowRef`, `quantity`, `notes`, `metadata` |
| `BookingCancellingHookData` | `bookingId`, `reason`, `currentStatus` |
| `BookingHookMap` | Indexed by `"booking:creating"` and `"booking:cancelling"`; extends `HookMap` so unknown keys still type-check as `unknown`. |
| `BookingCreatingHandler` | `HookHandler<BookingCreatingHookData>` |
| `BookingCancellingHandler` | `HookHandler<BookingCancellingHookData>` |
## Implementation Notes
- The `BookingHookMap` extends `HookMap` (an index-signature alias) so consumers can attach additional hook names without forking the type. The engine itself only fires the two documented entries.
- `metadata` is `Record<string, unknown>` to keep the map shape consistent across the two hook payloads; consumers wanting typed metadata cast inside the handler.
- Handlers receive `(data, context)` from the in-package `Hooks` implementation; calling `context.abort()` stops the chain and the engine receives whichever transformed data the previous handler produced.
- The named handler aliases save consumers a repetitive `HookHandler<BookingCreatingHookData>` annotation at every call site.

View File

@@ -0,0 +1,87 @@
# booking.types
## Purpose
Defines the `Booking` entity — the confirmed reservation — and the request/modification/cancellation shapes that surround it. `Booking` is generic over `TMeta extends Record<string, unknown>` so callers can attach typed metadata without losing the engine's type safety on the rest of the shape. Identifier and pricing references are flat — `BookingId` carries the composite `{ id, tenantId, createdAt }`, `totalPriceRef` carries the resolved `BookingPrice`. Keeping the shapes flat and JSON-safe makes the engine trivially serialisable to any store.
## Features
- `BookingId` — composite identifier (`id`, `tenantId`, `createdAt`).
- `CancellationTerms` — refund/penalty policy on the booking.
- `Booking<TMeta>` — confirmed booking entity.
- `HoldRequest`, `BookingRequest<TMeta>`, `BookingModification`, `WaitlistRequest` — input shapes for the engine's mutators.
- `CancellationResult` — output of `cancelBooking`.
## Usage: Basic
```ts
import type { BookingRequest } from "@suaveplan/booking";
const req: BookingRequest = {
bookableRef: "room-101",
customerRef: "cust-42",
windowRef: {
startTime: "2026-06-01T14:00:00Z",
endTime: "2026-06-03T11:00:00Z",
timezone: "UTC",
},
quantity: 1,
};
```
## Usage: Advanced
```ts
import type {
Booking, BookingModification, CancellationResult, HoldRequest, WaitlistRequest,
} from "@suaveplan/booking";
interface MyMeta extends Record<string, unknown> {
bedConfig: "king" | "twin";
requestedBreakfast: boolean;
}
const change: BookingModification = { quantity: 2, notes: "Extra guest" };
function isFullyRefundable(b: Booking<MyMeta>, c: CancellationResult): boolean {
return b.cancellationRef.refundable && c.penaltyAmount === 0;
}
function quickHold(bookableRef: string, customerRef: string): HoldRequest {
return {
bookableRef, customerRef, quantity: 1,
windowRef: {
startTime: new Date().toISOString(),
endTime: new Date(Date.now() + 3_600_000).toISOString(),
timezone: "UTC",
},
};
}
function joinWl(customerRef: string, bookableRef: string): WaitlistRequest {
return {
bookableRef, customerRef, priority: 1,
requestedWindowRef: quickHold(bookableRef, customerRef).windowRef,
};
}
```
## API Reference
| Type | Fields |
|---|---|
| `BookingId` | `id`, `tenantId`, `createdAt` |
| `CancellationTerms` | `refundable`, `deadlineBeforeStart`, `penaltyCalculation`, `penaltyValue` |
| `Booking<TMeta>` | `identifierRef`, `bookableRef`, `customerRef`, `windowRef`, `status`, `quantity`, `totalPriceRef`, `holdExpiresAt`, `cancellationRef`, `notes`, `metadata: TMeta` |
| `HoldRequest` | `bookableRef`, `customerRef`, `windowRef`, `quantity` |
| `BookingRequest<TMeta>` | `bookableRef`, `customerRef`, `windowRef`, `quantity`, `notes?`, `metadata?`, `holdId?` |
| `BookingModification` | `windowRef?`, `quantity?`, `notes?` |
| `CancellationResult` | `bookingId`, `refundAmount`, `penaltyAmount`, `status` |
| `WaitlistRequest` | `bookableRef`, `customerRef`, `requestedWindowRef`, `priority?` |
## Implementation Notes
- `Booking.holdExpiresAt` carries the original hold's expiry (or `""` if the booking didn't come from a hold) so audit trails preserve the upgrade path.
- `BookingRequest.metadata` is optional and typed via `TMeta`; consumers pinning a literal type get full IDE completion and compile-time safety.
- `CancellationResult.refundAmount` is the gross refund; `penaltyAmount` is whatever penalty was applied. `refundAmount - penaltyAmount` is the net to the customer.
- `BookingModification` is partial — undefined fields leave the existing booking unchanged.

View File

@@ -0,0 +1,76 @@
# capacity.types
## Purpose
Defines the data shapes used by the booking engine for capacity tracking, short-lived holds, and waitlist entries. These are the storage-tier shapes — the engine keeps them in `Map<string, ...>` registries today, but they round-trip cleanly through any persistent store. Keeping them flat and JSON-safe ensures the same shape works in-memory, on-disk, and on the wire.
## Features
- `OverbookingStrategy` — `"none" | "waitlist" | "percentage"` union.
- `CapacityPool` — totals + reserved + overbooking policy for a bookable.
- `BookingHold` — short-lived reservation with `expiresAt` and `quantity`.
- `WaitlistEntry` — priority-sorted queue entry with `requestedWindowRef`.
## Usage: Basic
```ts
import type { BookingHold } from "@suaveplan/booking";
const hold: BookingHold = {
holdId: "hold_abc",
bookableRef: "room-101",
customerRef: "cust-42",
windowRef: {
startTime: "2026-06-01T14:00:00Z",
endTime: "2026-06-03T11:00:00Z",
timezone: "UTC",
},
expiresAt: "2026-05-17T19:10:00Z",
quantity: 1,
};
```
## Usage: Advanced
```ts
import type { CapacityPool, OverbookingStrategy, WaitlistEntry } from "@suaveplan/booking";
const strategy: OverbookingStrategy = "waitlist";
const pool: CapacityPool = {
poolId: "p-stadium-block-a",
bookableRef: "stadium-2026-05-finals",
totalCapacity: 12000, reservedCount: 11850,
overbookingLimit: 0, overbookingStrategy: strategy,
};
const waiter: WaitlistEntry = {
entryId: "w_001",
bookableRef: pool.bookableRef,
customerRef: "cust-77",
requestedWindowRef: {
startTime: "2026-05-30T19:00:00Z",
endTime: "2026-05-30T22:00:00Z",
timezone: "Europe/London",
},
priority: 5,
createdAt: "2026-05-17T18:00:00Z",
notifiedAt: null,
};
```
## API Reference
| Type | Fields |
|---|---|
| `OverbookingStrategy` | `"none"` / `"waitlist"` / `"percentage"` literal union |
| `CapacityPool` | `poolId`, `bookableRef`, `totalCapacity`, `reservedCount`, `overbookingLimit`, `overbookingStrategy` |
| `BookingHold` | `holdId`, `bookableRef`, `customerRef`, `windowRef`, `expiresAt`, `quantity` |
| `WaitlistEntry` | `entryId`, `bookableRef`, `customerRef`, `requestedWindowRef`, `priority`, `createdAt`, `notifiedAt` |
## Implementation Notes
- `expiresAt` and `createdAt` are ISO-8601 strings — the engine's clock arithmetic happens in `Date.parse(...)` form to avoid coupling to a runtime `Date` instance.
- `WaitlistEntry.priority` is a numeric sort key — higher wins. The engine does not interpret it beyond `sort((a, b) => b.priority - a.priority)` at notify time.
- `notifiedAt` distinguishes the "queued" and "notified" states cleanly with `null` vs an ISO timestamp.
- `OverbookingStrategy === "percentage"` is metadata only — capacity policy enforcement is the consumer's concern (the booking engine does not implement overbooking math today).

View File

@@ -0,0 +1,75 @@
# config.types
## Purpose
Defines the complete JSON configuration tree for the booking engine. A single `BookingSystemConfig` value drives the entire pipeline — tenant identity, engine policy, availability cache settings, conflict-detection mode, pricing engine policy, holds/waitlist behaviour, notifications, rules + conditions, price catalogue, status state machine, bookable-type registry, layout, and locale. Every domain preset in `src/domains/` is essentially a factory returning a populated `BookingSystemConfig`, and the Zod schema in `src/schemas/config.schema.ts` validates JSON loaded from disk before it ever reaches the engine.
## Features
- `BookingSystemConfig` — the root configuration tree.
- 16 sub-types modelling every facet (`TenantConfig`, `EngineConfig`, `StrategyRegistration`, `HoldConfig`, `WaitlistConfig`, `NotificationConfig`, `NotificationTrigger`, `StatusTransition`, `AttributeDefinition`, `BookableTypeDefinition`, `LayoutConfig`, `LocaleConfig`, `AvailabilityConfig`, `ConflictDetectorConfig`, `PricingEngineConfig`).
- All shapes are pure data — no methods, no closures — so the entire tree round-trips through JSON.
## Usage: Basic
```ts
import { createSeatConfig } from "@suaveplan/booking";
import type { BookingSystemConfig } from "@suaveplan/booking";
const cfg: BookingSystemConfig = createSeatConfig("stadium-x", "Stadium X");
```
## Usage: Advanced
```ts
import type { BookingSystemConfig, StatusTransition } from "@suaveplan/booking";
import { createBaseConfig } from "@suaveplan/booking";
const refundOnlyOnceTransition: StatusTransition = {
from: "confirmed", to: "refunded",
allowedRoles: ["admin"], requiresReason: true, autoTransitionAfter: null,
};
const cfg: BookingSystemConfig = {
...createBaseConfig("clinic-1", "Heartbeat Clinic"),
pricingRef: {
defaultCurrency: "EUR",
supportedCurrencies: ["EUR", "USD"],
taxStrategy: "inclusive",
roundingMode: "round",
roundingPrecision: 2,
},
statusTransitions: [
...createBaseConfig("clinic-1", "Heartbeat Clinic").statusTransitions,
refundOnlyOnceTransition,
],
};
```
## API Reference
| Type | Notes |
|---|---|
| `TenantConfig` | `tenantId`, `tenantName`, `multiTenant`, `isolationLevel` |
| `EngineConfig` | `domainPreset`, `customStrategies`, `enableHolds`, `enableWaitlist`, `enableRecurring`, `maxBookingsPerCustomer`, `maxAdvanceBookingDays` |
| `StrategyRegistration` | `strategyType`, `strategyId`, `factoryFn` |
| `HoldConfig` | `enabled`, `defaultDurationMinutes`, `maxDurationMinutes`, `autoReleaseEnabled` |
| `WaitlistConfig` | `enabled`, `maxEntriesPerCustomer`, `autoNotify`, `notificationWindowMinutes`, `autoExpireAfterMinutes` |
| `NotificationTrigger` | `triggerId`, `eventName`, `channelRefs`, `templateRef`, `delayMinutes` |
| `NotificationConfig` | `enabled`, `channels`, `events: NotificationTrigger[]` |
| `StatusTransition` | `from`, `to`, `allowedRoles`, `requiresReason`, `autoTransitionAfter` |
| `AttributeDefinition` | `attributeId`, `label`, `dataType`, `enumValues`, `required`, `defaultValue` |
| `BookableTypeDefinition` | `typeId`, `label`, `description`, `requiredAttributes`, `optionalAttributes`, `defaultPricingModel`, `defaultConflictMode`, `supportsLayout` |
| `LayoutConfig` | `enabled`, `rootNodeRef`, `interactionMode`, `selectionMode`, `maxSelections`, `highlightAvailability`, `colorSchemeRef` |
| `LocaleConfig` | `defaultLocale`, `supportedLocales`, `dateFormat`, `timeFormat`, `firstDayOfWeek`, `currencyDisplay` |
| `AvailabilityConfig` | `refreshIntervalMs`, `cacheEnabled`, `cacheTtlMs` |
| `ConflictDetectorConfig` | `mode`, `allowSameBoundaryTurnover`, `bufferDuration`, `holdDuration`, `capacityMode` |
| `PricingEngineConfig` | `defaultCurrency`, `supportedCurrencies`, `taxStrategy`, `roundingMode`, `roundingPrecision` |
| `BookingSystemConfig` | Root — references every sub-type above plus `rules`, `conditions`, `priceRules`, `priceTiers`, `priceModifiers`, `seasonalRates`, `dynamicPricingRules`, `bookableTypeRegistry`, `layoutRef`, `localeRef`. |
## Implementation Notes
- `LayoutConfig` is nullable on the root (`layoutRef: LayoutConfig | null`) because not every domain uses spatial layouts.
- `autoTransitionAfter` is an ISO-8601 duration string or `null`. The engine does not implement the auto-transition timer itself; consumers schedule the firing.
- `bufferDuration` / `holdDuration` / `unitDuration` are ISO-8601 duration strings (`"PT15M"`, `"P1D"`); the engine parses them through `parseDurationMs`.
- The Zod schema in `src/schemas/config.schema.ts` mirrors this tree shape-for-shape; bumping a field here requires a paired update to the schema.

View File

@@ -0,0 +1,73 @@
# events.types
## Purpose
Defines the event vocabulary the booking engine emits. `BookingEventType` is the closed union of every lifecycle event consumers may subscribe to via `EventBus.on`. `BookingEvent<TPayload>` is the wrapper shape that flows through every handler. Closed enumeration over `BookingEventType` lets TypeScript catch typos at the call site (`engine.on("booking:confimed", …)` no longer compiles), and the parameterised `BookingEvent<TPayload>` keeps payload narrowing optional but available.
## Features
- `BookingEventType` — 15-entry string literal union covering booking, hold, waitlist, availability, and capacity lifecycle.
- `BookingEvent<TPayload>` — `{ eventType, timestamp, tenantId, payload }` envelope.
- `BookingEventHandler<TPayload>` — `(event: BookingEvent<TPayload>) => void` signature.
- `EventSubscription` — internal subscription record (`eventType`, `handler`, `id`).
## Usage: Basic
```ts
import { createBookingEngine } from "@suaveplan/booking";
import type { BookingEvent } from "@suaveplan/booking";
const engine = createBookingEngine();
engine.on("booking:confirmed", (event: BookingEvent) => {
console.log("confirmed at", event.timestamp, event.payload);
});
```
## Usage: Advanced
```ts
import type { Booking, BookingEvent, BookingEventHandler } from "@suaveplan/booking";
const onConfirmed: BookingEventHandler<Booking> = (event) => {
const booking = event.payload;
sendReceipt(booking.identifierRef.id, booking.customerRef);
};
const onCancelled: BookingEventHandler<{ bookingId: string; reason: string }> = (event) => {
const { bookingId, reason } = event.payload;
logCancellation(event.tenantId, bookingId, reason);
};
engine.on("booking:confirmed", onConfirmed);
engine.on("booking:cancelled", onCancelled);
```
## API Reference
### `BookingEventType`
Union of 15 string literals: `"booking:created"`, `"booking:confirmed"`, `"booking:cancelled"`, `"booking:modified"`, `"booking:completed"`, `"booking:no_show"`, `"booking:checked_in"`, `"hold:created"`, `"hold:expired"`, `"hold:converted"`, `"waitlist:joined"`, `"waitlist:notified"`, `"waitlist:promoted"`, `"availability:changed"`, `"capacity:updated"`.
### `BookingEvent<TPayload>`
| Field | Type | Notes |
|---|---|---|
| `eventType` | `BookingEventType` | One of the 15 literals. |
| `timestamp` | `string` | ISO-8601 datetime; engine fills this in. |
| `tenantId` | `string` | Always present; sourced from `BookingSystemConfig.tenantRef.tenantId`. |
| `payload` | `TPayload` | Per-event payload (booking, hold, waitlist entry, etc.). |
### `BookingEventHandler<TPayload>`
`(event: BookingEvent<TPayload>) => void`. Handlers are invoked synchronously in registration order. They MUST NOT throw — uncaught errors propagate to the engine.
### `EventSubscription`
Internal record returned/managed by `EventBus`. Consumers only see the subscription `id` returned from `on`.
## Implementation Notes
- The 15-entry union is intentionally closed; adding a new event requires changing this file and is treated as a breaking change in spirit (consumers may have exhaustive switches).
- Payload typing is by convention — the engine emits the documented payload for each event but does not enforce it at the type level (would require a discriminated `BookingEventMap`). Consumers typing handler payloads via the `BookingEventHandler<MyPayload>` generic get the narrowing they want.
- `timestamp` is always present at emit time — handlers should not regenerate it.
- The engine does not retain handlers across `clear()`; consumers can re-register after a reset.

View File

@@ -0,0 +1,63 @@
# layout.types
## Purpose
Defines the geometric shapes used by visualisation consumers (seat maps, floor plans, venue diagrams) when rendering the booking engine's bookables. The engine itself does not interpret these shapes — it carries them as opaque payloads — but they ship in the typed core so every consumer agrees on the wire format. The layout types are deliberately framework-agnostic: `NodePosition` covers SVG, Canvas, and WebGL renderers; `LayoutColorScheme` covers any palette implementation.
## Features
- `NodePosition` — 2D position + size + rotation + shape + svgPath.
- `LayoutNode` — recursive tree node (parent ref + metadata bag).
- `LayoutColorScheme` — six colour roles (`available`, `selected`, `unavailable`, `held`, `disabled`, `hover`).
- All shapes are JSON-safe — no DOM nodes, no React refs, no canvas contexts.
## Usage: Basic
```ts
import type { LayoutColorScheme } from "@suaveplan/booking";
const palette: LayoutColorScheme = {
available: "#22c55e",
selected: "#3b82f6",
unavailable: "#ef4444",
held: "#f59e0b",
disabled: "#9ca3af",
hover: "#a855f7",
};
```
## Usage: Advanced
```ts
import type { LayoutNode, NodePosition } from "@suaveplan/booking";
const seatPos: NodePosition = {
x: 120, y: 80, width: 32, height: 32, rotation: 0,
shape: "circle", svgPath: "",
};
const seat: LayoutNode = {
nodeId: "seat-A12",
parentNodeId: "row-A",
label: "A12",
nodeType: "seat",
positionRef: seatPos,
metadata: { tier: "vip", isAccessible: true },
};
```
## API Reference
| Type | Fields |
|---|---|
| `NodePosition` | `x`, `y`, `width`, `height`, `rotation`, `shape`, `svgPath` |
| `LayoutNode` | `nodeId`, `parentNodeId` (nullable), `label`, `nodeType`, `positionRef`, `metadata` |
| `LayoutColorScheme` | `available`, `selected`, `unavailable`, `held`, `disabled`, `hover` (all `string`) |
## Implementation Notes
- `parentNodeId: null` marks the root node; otherwise renderers walk parent→child to compose the tree.
- `shape` is a string (e.g. `"circle"`, `"rect"`, `"path"`) so renderers can dispatch without coupling to an enum the package owns.
- `svgPath` is only meaningful when `shape === "path"`; otherwise empty string.
- `metadata` is `Record<string, unknown>` so domains can attach arbitrary annotations (tier, isAccessible, hasObstructedView) without widening the core type.
- Colours are stored as strings; the engine does not validate format. Renderers parse them as CSS / hex / Three.js inputs as needed.

View File

@@ -0,0 +1,80 @@
# pricing.types
## Purpose
Defines the data shapes used by the booking engine's pricing pipeline. Every entity here is JSON-serialisable so prices, rules, modifiers, and the seasonal/dynamic strategies that drive them can travel from a config file to an in-process registry to a network boundary without any reshape. The shapes intentionally model "what" rather than "how" — there are no calculation methods on the interfaces; `src/pricing/calculator/calculator.ts` and its siblings own the pipeline.
## Features
- `PriceRule` — base unit of pricing (`fixed`, `per_unit`, `per_duration`, `tiered`).
- `PriceTier` — quantity-bracket override for `tiered` rules.
- `PriceModifier` — `discount`, `surcharge`, `fee`, or `tax` applied on top.
- `SeasonalRate` — month/day window with a multiplier (supports wrap-around-year).
- `DynamicPricingRule` — `occupancy_threshold`, `advance_days`, or `demand_score` trigger.
- `BookingPrice` — fully resolved price line carrying subtotal/tax/fees/discount/total + breakdown.
## Usage: Basic
```ts
import type { BookingPrice, PriceRule } from "@suaveplan/booking";
const nightRate: PriceRule = {
ruleId: "rate-standard-night",
amount: 12000,
currency: "USD",
pricingModel: "per_duration",
unitDuration: "P1D",
};
const charged: BookingPrice = {
subtotal: 24000, tax: 2400, fees: 0, discount: 0, total: 26400,
currency: "USD", breakdown: ["rate-standard-night"],
};
```
## Usage: Advanced
```ts
import type {
DynamicPricingRule, PriceModifier, PriceTier, SeasonalRate,
} from "@suaveplan/booking";
const groupDiscount: PriceTier = {
tierId: "t-group", ruleRef: "rate-standard-night",
minQuantity: 6, maxQuantity: 20, amount: 9000,
};
const seasonal: SeasonalRate = {
rateId: "winter", priceRuleRef: "rate-standard-night",
seasonName: "Winter", startMonth: 12, startDay: 1,
endMonth: 2, endDay: 28, multiplier: 1.25,
};
const surge: DynamicPricingRule = {
ruleId: "surge-90", priceRuleRef: "rate-standard-night",
triggerType: "occupancy_threshold", threshold: 0.9, multiplier: 1.5,
};
const cleaningFee: PriceModifier = {
modifierId: "m-cleaning", type: "fee",
calculationMethod: "flat", value: 5000, conditionRef: "",
};
```
## API Reference
| Type | Fields |
|---|---|
| `PriceRule` | `ruleId`, `amount`, `currency`, `pricingModel`, `unitDuration` |
| `PriceTier` | `tierId`, `ruleRef`, `minQuantity`, `maxQuantity`, `amount` |
| `PriceModifier` | `modifierId`, `type` (`discount`/`surcharge`/`fee`/`tax`), `calculationMethod` (`flat`/`percentage`), `value`, `conditionRef` |
| `SeasonalRate` | `rateId`, `priceRuleRef`, `seasonName`, `startMonth`, `startDay`, `endMonth`, `endDay`, `multiplier` |
| `DynamicPricingRule` | `ruleId`, `priceRuleRef`, `triggerType` (`occupancy_threshold`/`advance_days`/`demand_score`), `threshold`, `multiplier` |
| `BookingPrice` | `subtotal`, `tax`, `fees`, `discount`, `total`, `currency`, `breakdown` |
## Implementation Notes
- Amounts are stored as `number` (units defined by the calling system — typically minor units when integrating with `@suaveplan/money`).
- `SeasonalRate` supports wrap-around-year intervals (e.g. Dec 1 → Feb 28) via the disjunctive overlap test in `src/pricing/seasonal/seasonal.ts`.
- `PriceModifier.type === "tax"` is special-cased — tax modifiers are summed by `calculateTax` and ignored by `applyModifiers` to avoid double counting.
- `BookingPrice.breakdown` carries the audit trail: every applied rule + modifier id, in order.

View File

@@ -0,0 +1,73 @@
# rules.types
## Purpose
Defines the data shapes the rules engine evaluates. `BookingRule` is the top-level entity referenced from `BookingSystemConfig.rules`; each rule references one or more `RuleCondition` ids (`conditionRefs`) plus a terminal `RuleAction`. The evaluator in `src/rules/evaluator/evaluator.ts` walks this graph, picks matching conditions out of the config's `conditions[]` array, and produces a `RuleEvaluationResult` that the engine consults before confirming a booking. Keeping the shapes flat and id-based — rather than embedding conditions inside rules — makes the config trivially editable, diff-able, and serialisable.
## Features
- `RuleAction` — terminal action of a rule (`actionType`, `message`, optional `modifierRef`).
- `RuleCondition` — pure predicate (`field`, `operator`, `value`).
- `BookingRule` — ordered collection of condition refs + action + priority + enabled flag.
- `RuleEvaluationResult` — `{ passed, failedRules, appliedModifiers, messages }` returned to the engine.
## Usage: Basic
```ts
import type { BookingRule, RuleCondition } from "@suaveplan/booking";
const deny: BookingRule = {
ruleId: "no-bookings-under-1h",
name: "Minimum booking length",
priority: 1,
enabled: true,
conditionRefs: ["c-min-1h"],
conditionLogic: "all",
actionRef: { actionType: "deny", message: "Minimum 1 hour", modifierRef: null },
};
const cond: RuleCondition = {
conditionId: "c-min-1h",
field: "window.durationHours",
operator: "lt",
value: 1,
};
```
## Usage: Advanced
```ts
import type { RuleAction, RuleEvaluationResult } from "@suaveplan/booking";
const apply: RuleAction = {
actionType: "apply_modifier",
message: "Weekend surcharge",
modifierRef: "m-weekend-15pct",
};
function handle(result: RuleEvaluationResult): boolean {
if (!result.passed) {
console.warn("Booking denied:", result.messages.join("; "));
return false;
}
for (const id of result.appliedModifiers) applyModifier(id);
return true;
}
```
## API Reference
| Type | Fields |
|---|---|
| `RuleAction` | `actionType` (`"deny"`/`"apply_modifier"`/etc.), `message`, `modifierRef` (nullable) |
| `RuleCondition` | `conditionId`, `field`, `operator` (11 supported), `value` (`unknown`) |
| `BookingRule` | `ruleId`, `name`, `priority` (lower = first), `enabled`, `conditionRefs[]`, `conditionLogic` (`"all"`/`"any"`), `actionRef` |
| `RuleEvaluationResult` | `passed`, `failedRules`, `appliedModifiers`, `messages` |
## Implementation Notes
- Conditions are referenced by id, not embedded, so a single condition can be reused across many rules without duplication.
- `conditionLogic` is `"all"` by default; `"any"` is permissive. Unknown values fall through to `"all"` semantics in the evaluator.
- `priority: 0` runs first; the evaluator sorts ascending. Higher-priority rules can short-circuit by emitting `deny`.
- `value: unknown` is intentional — operators like `between` and `in` accept arrays; `regex` accepts strings; `eq`/`neq` accept any JSON-comparable value.
- `RuleEvaluationResult.failedRules` carries the entire `BookingRule` (not just its id) so callers can surface `actionRef.message` directly to the user.

View File

@@ -0,0 +1,83 @@
# temporal.types
## Purpose
Defines the shared shape of every time-based value the booking engine handles. `TimeWindow` is the canonical half-open interval used by every overlap, merge, split, and validity helper in `src/temporal/windows`; `RecurrenceRule` is the JSON-serialisable rule consumed by `src/temporal/recurrence` to expand repeating occurrences; `TimeSlot` is the materialised result that consumers display, hold, and book. Keeping these as pure data interfaces — no methods, no behaviour — lets every layer of the package (and downstream consumers) reason about them without coupling to a runtime implementation.
## Features
- `TimeWindow` — `{ startTime, endTime, timezone }` triple in ISO-8601 form.
- `RecurrenceRule` — `{ frequency, interval, daysOfWeek, endDate, exceptions }` capable of representing daily/weekly/monthly/yearly recurrences with weekday filters and date exceptions.
- `TimeSlot` — `{ slotId, windowRef, recurrenceRef, capacityRemaining }` carrying the bookable window plus capacity.
- All three shapes are JSON-safe: no `Date` objects, no symbols, no functions.
## Usage: Basic
```ts
import type { TimeWindow } from "@suaveplan/booking";
const dinner: TimeWindow = {
startTime: "2026-05-17T19:00:00Z",
endTime: "2026-05-17T21:00:00Z",
timezone: "Europe/London",
};
```
## Usage: Advanced
```ts
import type { RecurrenceRule, TimeSlot, TimeWindow } from "@suaveplan/booking";
const everyMonWed: RecurrenceRule = {
frequency: "weekly",
interval: 1,
daysOfWeek: [1, 3],
endDate: "2026-12-31T00:00:00Z",
exceptions: ["2026-12-25T00:00:00Z"],
};
function holdSlot(window: TimeWindow): TimeSlot {
return {
slotId: `slot_${Date.now().toString(36)}`,
windowRef: window,
recurrenceRef: null,
capacityRemaining: 10,
};
}
```
## API Reference
### `TimeWindow`
| Field | Type | Notes |
|---|---|---|
| `startTime` | `string` | ISO-8601 datetime; half-open lower bound. |
| `endTime` | `string` | ISO-8601 datetime; half-open upper bound. |
| `timezone` | `string` | IANA zone, e.g. `"America/New_York"`. |
### `RecurrenceRule`
| Field | Type | Notes |
|---|---|---|
| `frequency` | `string` | `"daily"`, `"weekly"`, `"monthly"`, `"yearly"`. |
| `interval` | `number` | Step between occurrences (1 = every period). |
| `daysOfWeek` | `number[]` | 0=Sun..6=Sat. Only meaningful for weekly. |
| `endDate` | `string` | ISO-8601 datetime or empty string. |
| `exceptions` | `string[]` | ISO-8601 dates whose day-key skips the occurrence. |
### `TimeSlot`
| Field | Type | Notes |
|---|---|---|
| `slotId` | `string` | Unique identifier. |
| `windowRef` | `TimeWindow` | The slot's bookable window. |
| `recurrenceRef` | `RecurrenceRule \| null` | Pattern this slot was expanded from, if any. |
| `capacityRemaining` | `number` | Currently available capacity (>= 0). |
## Implementation Notes
- Half-open intervals: `windowsOverlap(a, b)` is true iff `a.start < b.end && b.start < a.end`. A booking ending exactly when another starts does NOT overlap (use `allowSameBoundaryTurnover` to model hotel-turnover policies).
- Timezone is informational; arithmetic uses UTC via `Date.parse(...)`. Callers wanting wall-clock semantics convert separately.
- `RecurrenceRule.exceptions` are matched by the ISO date-only prefix (YYYY-MM-DD); time-of-day in the exception is ignored.
- The package keeps these shapes flat by design — no nested `start: { iso, tz }` objects, no `Date` instances — so they round-trip through `JSON.parse(JSON.stringify(...))` losslessly.

View File

@@ -0,0 +1,74 @@
# utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Shared, dependency-free helpers used across `@suaveplan/booking`. Every export here is pure, side-effect-free, and runs in any JavaScript runtime (Bun, Node, browser, Web Worker). Keeping these in one module avoids duplication across feature modules and gives the conflict/availability/pricing code a single place to look for primitives like ISO-8601 duration parsing or recursive object freezing.
## Features
- `parseDurationMs(duration)` — minimal ISO-8601 duration parser supporting `D`, `H`, `M`, `S` components.
- `generateId(prefix)` — `<prefix>_<base36 ms>_<base36 random>` identifier.
- `clampNumber(value, min, max)` — interval clamp.
- `isNonEmptyString(value)` — type-guard for non-empty strings.
- `deepFreeze(obj)` — recursive `Object.freeze`.
## Usage: Basic
```ts
import { clampNumber, parseDurationMs } from "@suaveplan/booking";
parseDurationMs("PT15M"); // 900000
clampNumber(150, 0, 100); // 100
```
## Usage: Advanced
```ts
import { deepFreeze, generateId, isNonEmptyString } from "@suaveplan/booking";
const id = generateId("hold");
// id matches /^hold_[0-9a-z]+_[0-9a-z]+$/
const config = deepFreeze({
pricing: { defaultCurrency: "USD" },
limits: { maxBookingsPerCustomer: 5 },
});
// config.pricing.defaultCurrency = "EUR"; ← throws in strict mode
function take(x: unknown): string {
if (!isNonEmptyString(x)) throw new Error("expected non-empty string");
return x.toUpperCase();
}
```
## API Reference
### `parseDurationMs(duration: string): number`
Parses a subset of ISO-8601 duration syntax (`P…D…T…H…M…S`). Empty string returns `0`. Components missing from the input contribute `0`. Year/month components are NOT supported (the engine never needs them — recurrence uses calendar arithmetic).
### `generateId(prefix: string): string`
Returns `<prefix>_<base36 Date.now()>_<6 base36 chars>`. Uses `Math.random()` — adequate for in-memory uniqueness, not cryptographic.
### `clampNumber(value: number, min: number, max: number): number`
Returns `Math.min(Math.max(value, min), max)`. Undefined behaviour if `min > max`.
### `isNonEmptyString(value: unknown): value is string`
Type-guard returning `true` iff `typeof value === "string" && value.length > 0`.
### `deepFreeze<T>(obj: T): Readonly<T>`
Recursively freezes every nested object. Arrays are frozen as well. Cycles are not supported (would infinite-loop) — only acyclic structures.
## Implementation Notes
- `parseDurationMs` is deliberately minimal — it covers every duration the engine sees in production (`bufferDuration`, `holdDuration`, `unitDuration`, `autoTransitionAfter`). Years/months are excluded because the engine does not interpret them.
- `generateId` is deterministic enough for in-memory tests (the random suffix makes collisions vanishingly unlikely across the call counts a booking engine handles) but is not crypto-grade. A future change can thread an injectable `IdGenerator` from `@suaveplan/types` once consumers need deterministic ids.
- `deepFreeze` mutates its argument (calling `Object.freeze`) and returns the same reference typed `Readonly<T>`. Callers that need a copy should `structuredClone` first.