Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
# `commerce`
Booking engines, contact management, payment plugins, and commerce primitives.
<!-- begin:auto-docs -->
## Subcategories
2 subcategories, 5 packages.
### `booking`
Universal booking engine with availability, conflict detection, and pricing.
| Package | Description |
|---|---|
| [`@suaveplan/booking`](./booking/README.md) | Universal, JSON-configurable booking engine with pluggable strategies for availability resolution, conflict detection, pricing calculation, and rule evalu… |
### `contacts`
Contact and group management with deduplication and import/export.
| Package | Description |
|---|---|
| [`@suaveplan/api-plugin-contacts`](./api-plugin-contacts/README.md) | Contacts and Groups HTTP plugin for `@suaveplan/api`. Mounts the `/contacts` and `/groups` route trees on a Hono `OpenAPIHono`, backed by SurrealDB implementat… |
| [`@suaveplan/audience-rules`](./audience-rules/README.md) | Universal audience segmentation: rule-based filtering on contact data with 14 comparison operators, AND/OR chaining, dot-notation field resolution includi… |
| [`@suaveplan/contacts`](./contacts/README.md) | Universal contact and group management with hierarchical nesting, audience resolution, contact merger, and CSV/JSON import-export. Multi-tenant, channel-a… |
| [`@suaveplan/macos-contacts`](./macos-contacts/README.md) | Universal bridge to macOS Contacts.app via AppleScript. Reads contacts and groups through the platform `osascript` binary, parses the output into typed re… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1,3 @@
# `commerce`
Booking engines, contact management, payment plugins, and commerce primitives.

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 evalu… |
<!-- 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,137 @@
{
"name": "@suaveplan/booking",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./error-codes/error-codes": {
"import": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" }
},
"./hooks/hooks": {
"import": { "types": "./dist/hooks/hooks.d.ts", "default": "./dist/hooks/hooks.js" }
},
"./core/engine/engine": {
"import": { "types": "./dist/core/engine/engine.d.ts", "default": "./dist/core/engine/engine.js" }
},
"./core/state-machine/state-machine": {
"import": { "types": "./dist/core/state-machine/state-machine.d.ts", "default": "./dist/core/state-machine/state-machine.js" }
},
"./core/event-bus/event-bus": {
"import": { "types": "./dist/core/event-bus/event-bus.d.ts", "default": "./dist/core/event-bus/event-bus.js" }
},
"./availability/resolver/resolver": {
"import": { "types": "./dist/availability/resolver/resolver.d.ts", "default": "./dist/availability/resolver/resolver.js" }
},
"./availability/strategies/strategies": {
"import": { "types": "./dist/availability/strategies/strategies.d.ts", "default": "./dist/availability/strategies/strategies.js" }
},
"./conflict/detector/detector": {
"import": { "types": "./dist/conflict/detector/detector.d.ts", "default": "./dist/conflict/detector/detector.js" }
},
"./conflict/exact-match/exact-match": {
"import": { "types": "./dist/conflict/exact-match/exact-match.d.ts", "default": "./dist/conflict/exact-match/exact-match.js" }
},
"./conflict/range-overlap/range-overlap": {
"import": { "types": "./dist/conflict/range-overlap/range-overlap.d.ts", "default": "./dist/conflict/range-overlap/range-overlap.js" }
},
"./conflict/slot-capacity/slot-capacity": {
"import": { "types": "./dist/conflict/slot-capacity/slot-capacity.d.ts", "default": "./dist/conflict/slot-capacity/slot-capacity.js" }
},
"./conflict/provider-overlap/provider-overlap": {
"import": { "types": "./dist/conflict/provider-overlap/provider-overlap.d.ts", "default": "./dist/conflict/provider-overlap/provider-overlap.js" }
},
"./pricing/calculator/calculator": {
"import": { "types": "./dist/pricing/calculator/calculator.d.ts", "default": "./dist/pricing/calculator/calculator.js" }
},
"./pricing/seasonal/seasonal": {
"import": { "types": "./dist/pricing/seasonal/seasonal.d.ts", "default": "./dist/pricing/seasonal/seasonal.js" }
},
"./pricing/dynamic/dynamic": {
"import": { "types": "./dist/pricing/dynamic/dynamic.d.ts", "default": "./dist/pricing/dynamic/dynamic.js" }
},
"./rules/evaluator/evaluator": {
"import": { "types": "./dist/rules/evaluator/evaluator.d.ts", "default": "./dist/rules/evaluator/evaluator.js" }
},
"./rules/conditions/conditions": {
"import": { "types": "./dist/rules/conditions/conditions.d.ts", "default": "./dist/rules/conditions/conditions.js" }
},
"./temporal/windows/windows": {
"import": { "types": "./dist/temporal/windows/windows.d.ts", "default": "./dist/temporal/windows/windows.js" }
},
"./temporal/recurrence/recurrence": {
"import": { "types": "./dist/temporal/recurrence/recurrence.d.ts", "default": "./dist/temporal/recurrence/recurrence.js" }
},
"./temporal/slots/slots": {
"import": { "types": "./dist/temporal/slots/slots.d.ts", "default": "./dist/temporal/slots/slots.js" }
},
"./domains/seats/seats": {
"import": { "types": "./dist/domains/seats/seats.d.ts", "default": "./dist/domains/seats/seats.js" }
},
"./domains/accommodation/accommodation": {
"import": { "types": "./dist/domains/accommodation/accommodation.d.ts", "default": "./dist/domains/accommodation/accommodation.js" }
},
"./domains/appointments/appointments": {
"import": { "types": "./dist/domains/appointments/appointments.d.ts", "default": "./dist/domains/appointments/appointments.js" }
},
"./domains/resources/resources": {
"import": { "types": "./dist/domains/resources/resources.d.ts", "default": "./dist/domains/resources/resources.js" }
},
"./domains/activities/activities": {
"import": { "types": "./dist/domains/activities/activities.d.ts", "default": "./dist/domains/activities/activities.js" }
},
"./domains/transport/transport": {
"import": { "types": "./dist/domains/transport/transport.d.ts", "default": "./dist/domains/transport/transport.js" }
},
"./schemas/booking.schema": {
"import": { "types": "./dist/schemas/booking.schema.d.ts", "default": "./dist/schemas/booking.schema.js" }
},
"./schemas/config.schema": {
"import": { "types": "./dist/schemas/config.schema.d.ts", "default": "./dist/schemas/config.schema.js" }
},
"./utils/utils": {
"import": { "types": "./dist/utils/utils.d.ts", "default": "./dist/utils/utils.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,11 @@
export {
clearStrategyRegistry,
getRegisteredStrategies,
registerAvailabilityStrategy,
resolveAvailability,
} from "./resolver.js";
export type {
AvailabilityQuery,
AvailabilityResult,
AvailabilityStrategyFn,
} from "./resolver.types.js";

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,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BOOKING_CODES, BookingError } from "../../error-codes/error-codes.js";
import {
clearStrategyRegistry,
getRegisteredStrategies,
registerAvailabilityStrategy,
resolveAvailability,
} from "./resolver.js";
const bookable = {
identifierRef: { id: "b", tenantId: "t", categoryId: "c" },
name: "B",
description: "",
bookableType: "x",
status: "active",
layoutNodeRef: null,
capacityRef: "",
priceRuleRefs: [],
timeSlotRefs: [],
attributes: {},
};
const window = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
};
describe("resolver", () => {
it("unknown strategy raises BOOKING.UNKNOWN_AVAILABILITY_STRATEGY", {
timeout: 5000,
}, () => {
clearStrategyRegistry();
try {
resolveAvailability(
"nope",
bookable,
{ bookableRef: "b", windowRef: window, quantity: 1 },
[]
);
} catch (err) {
expect(err).toBeInstanceOf(BookingError);
if (err instanceof BookingError) {
expect(err.code).toBe(
BOOKING_CODES.UNKNOWN_AVAILABILITY_STRATEGY
);
expect(err.strategy).toBe("nope");
return;
}
}
expect.fail("expected throw");
});
it("register/get/clear lifecycle", { timeout: 5000 }, () => {
clearStrategyRegistry();
expect(getRegisteredStrategies()).toEqual([]);
registerAvailabilityStrategy("always", () => ({
available: true,
remainingCapacity: 1,
conflictingBookings: [],
suggestedAlternatives: [],
}));
expect(getRegisteredStrategies()).toEqual(["always"]);
const result = resolveAvailability(
"always",
bookable,
{ bookableRef: "b", windowRef: window, quantity: 1 },
[]
);
expect(result.available).toBe(true);
clearStrategyRegistry();
});
});

View File

@@ -0,0 +1,53 @@
/**
* Generic availability resolver. Delegates to registered strategies based on
* booking domain configuration.
*/
import { BOOKING_CODES, BookingError } from "../../error-codes/error-codes.js";
import type { Bookable } from "../../types/bookable.types.js";
import type { Booking } from "../../types/booking.types.js";
import type {
AvailabilityQuery,
AvailabilityResult,
AvailabilityStrategyFn,
} from "./resolver.types.js";
const strategyRegistry = new Map<string, AvailabilityStrategyFn>();
export function registerAvailabilityStrategy(
strategyId: string,
strategy: AvailabilityStrategyFn
): void {
strategyRegistry.set(strategyId, strategy);
}
export function resolveAvailability<
TMeta extends Record<string, unknown> = Record<string, unknown>,
>(
strategyId: string,
bookable: Bookable<TMeta>,
query: AvailabilityQuery,
existingBookings: Booking<TMeta>[]
): AvailabilityResult {
const strategy = strategyRegistry.get(strategyId);
if (!strategy) {
throw new BookingError(
BOOKING_CODES.UNKNOWN_AVAILABILITY_STRATEGY,
`Unknown availability strategy: '${strategyId}'`,
{ strategy: strategyId }
);
}
return (strategy as AvailabilityStrategyFn<TMeta>)(
bookable,
query,
existingBookings
);
}
export function getRegisteredStrategies(): string[] {
return [...strategyRegistry.keys()];
}
export function clearStrategyRegistry(): void {
strategyRegistry.clear();
}

View File

@@ -0,0 +1,28 @@
/**
* Availability resolver type definitions.
*/
import type { Bookable } from "../../types/bookable.types.js";
import type { Booking } from "../../types/booking.types.js";
import type { TimeWindow } from "../../types/temporal.types.js";
export interface AvailabilityQuery {
bookableRef: string;
windowRef: TimeWindow;
quantity: number;
}
export interface AvailabilityResult {
available: boolean;
remainingCapacity: number;
conflictingBookings: string[];
suggestedAlternatives: TimeWindow[];
}
export type AvailabilityStrategyFn<
TMeta extends Record<string, unknown> = Record<string, unknown>,
> = (
bookable: Bookable<TMeta>,
query: AvailabilityQuery,
existingBookings: Booking<TMeta>[]
) => AvailabilityResult;

View File

@@ -0,0 +1,6 @@
export {
capacityStrategy,
exactMatchStrategy,
rangeOverlapStrategy,
} from "./strategies.js";
export type { SlotAvailability } from "./strategies.types.js";

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,158 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Bookable } from "../../types/bookable.types.js";
import type { Booking } from "../../types/booking.types.js";
import {
capacityStrategy,
exactMatchStrategy,
rangeOverlapStrategy,
} from "./strategies.js";
const bookable: Bookable = {
identifierRef: { id: "b1", tenantId: "t", categoryId: "c" },
name: "B1",
description: "",
bookableType: "x",
status: "active",
layoutNodeRef: null,
capacityRef: "",
priceRuleRefs: [],
timeSlotRefs: [],
attributes: {},
};
function makeBooking(
id: string,
ref: string,
start: string,
end: string,
qty = 1
): Booking {
return {
identifierRef: { id, tenantId: "t", createdAt: start },
bookableRef: ref,
customerRef: "c",
status: "confirmed",
windowRef: { startTime: start, endTime: end, timezone: "UTC" },
quantity: qty,
totalPriceRef: {
subtotal: 0,
tax: 0,
fees: 0,
discount: 0,
total: 0,
currency: "USD",
breakdown: [],
},
holdExpiresAt: "",
notes: "",
metadata: {},
cancellationRef: {
refundable: false,
deadlineBeforeStart: "",
penaltyCalculation: "",
penaltyValue: 0,
},
};
}
const window = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
};
describe("strategies", () => {
it("exactMatchStrategy / rangeOverlapStrategy / capacityStrategy behave as documented", {
timeout: 5000,
}, () => {
// exactMatch
const ex = exactMatchStrategy(
bookable,
{ bookableRef: "b1", windowRef: window, quantity: 1 },
[makeBooking("b-1", "b1", window.startTime, window.endTime)]
);
expect(ex.available).toBe(false);
expect(ex.conflictingBookings).toEqual(["b-1"]);
const exFree = exactMatchStrategy(
bookable,
{ bookableRef: "b1", windowRef: window, quantity: 1 },
[]
);
expect(exFree.available).toBe(true);
// rangeOverlap
const range = rangeOverlapStrategy(
bookable,
{ bookableRef: "b1", windowRef: window, quantity: 1 },
[
makeBooking(
"b-2",
"b1",
"2026-06-01T10:30:00Z",
"2026-06-01T11:30:00Z"
),
]
);
expect(range.available).toBe(false);
const rangeFree = rangeOverlapStrategy(
bookable,
{ bookableRef: "b1", windowRef: window, quantity: 1 },
[]
);
expect(rangeFree.available).toBe(true);
expect(rangeFree.remainingCapacity).toBe(1);
// capacity
const overload: Booking[] = [
makeBooking(
"b-3",
"b1",
"2026-06-01T10:00:00Z",
"2026-06-01T11:00:00Z",
50
),
makeBooking(
"b-4",
"b1",
"2026-06-01T10:00:00Z",
"2026-06-01T11:00:00Z",
50
),
];
const cap = capacityStrategy(
bookable,
{ bookableRef: "b1", windowRef: window, quantity: 5 },
overload
);
expect(cap.available).toBe(false);
expect(cap.remainingCapacity).toBe(0);
const capFree = capacityStrategy(
bookable,
{ bookableRef: "b1", windowRef: window, quantity: 5 },
[]
);
expect(capFree.available).toBe(true);
expect(capFree.remainingCapacity).toBe(100);
// Available with some overlapping bookings (capacity not exhausted)
const capPartial = capacityStrategy(
bookable,
{ bookableRef: "b1", windowRef: window, quantity: 5 },
[
makeBooking(
"b-5",
"b1",
"2026-06-01T10:00:00Z",
"2026-06-01T11:00:00Z",
30
),
]
);
expect(capPartial.available).toBe(true);
expect(capPartial.remainingCapacity).toBe(70);
expect(capPartial.conflictingBookings).toEqual([]);
});
});

View File

@@ -0,0 +1,77 @@
/**
* Built-in availability strategies for common booking domains. Each strategy
* computes availability differently based on domain rules.
*/
import { windowsOverlap } from "../../temporal/windows/windows.js";
import type { Bookable } from "../../types/bookable.types.js";
import type { Booking } from "../../types/booking.types.js";
import type {
AvailabilityQuery,
AvailabilityResult,
} from "../resolver/resolver.types.js";
export function exactMatchStrategy<TMeta extends Record<string, unknown>>(
_bookable: Bookable<TMeta>,
query: AvailabilityQuery,
existingBookings: Booking<TMeta>[]
): AvailabilityResult {
const conflicts = existingBookings.filter(
(b) =>
b.bookableRef === query.bookableRef &&
b.windowRef.startTime === query.windowRef.startTime &&
b.windowRef.endTime === query.windowRef.endTime
);
return {
available: conflicts.length === 0,
remainingCapacity: conflicts.length === 0 ? 1 : 0,
conflictingBookings: conflicts.map((b) => b.identifierRef.id),
suggestedAlternatives: [],
};
}
export function rangeOverlapStrategy<TMeta extends Record<string, unknown>>(
_bookable: Bookable<TMeta>,
query: AvailabilityQuery,
existingBookings: Booking<TMeta>[]
): AvailabilityResult {
const conflicts = existingBookings.filter(
(b) =>
b.bookableRef === query.bookableRef &&
windowsOverlap(b.windowRef, query.windowRef, true)
);
return {
available: conflicts.length === 0,
remainingCapacity: conflicts.length === 0 ? 1 : 0,
conflictingBookings: conflicts.map((b) => b.identifierRef.id),
suggestedAlternatives: [],
};
}
export function capacityStrategy<TMeta extends Record<string, unknown>>(
_bookable: Bookable<TMeta>,
query: AvailabilityQuery,
existingBookings: Booking<TMeta>[]
): AvailabilityResult {
const overlapping = existingBookings.filter(
(b) =>
b.bookableRef === query.bookableRef &&
windowsOverlap(b.windowRef, query.windowRef)
);
const bookedQuantity = overlapping.reduce((sum, b) => sum + b.quantity, 0);
const totalCapacity = 100;
const remaining = totalCapacity - bookedQuantity;
const available = remaining >= query.quantity;
return {
available,
remainingCapacity: Math.max(0, remaining),
conflictingBookings: available
? []
: overlapping.map((b) => b.identifierRef.id),
suggestedAlternatives: [],
};
}

View File

@@ -0,0 +1,12 @@
/**
* Availability strategy type definitions.
*/
import type { TimeWindow } from "../../types/temporal.types.js";
export interface SlotAvailability {
slotId: string;
windowRef: TimeWindow;
available: boolean;
remainingCapacity: number;
}

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,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BOOKING_CODES, BookingError } from "../../error-codes/error-codes.js";
import {
clearDetectorRegistry,
detectConflicts,
getRegisteredModes,
registerDetector,
} from "./detector.js";
const cfg = {
mode: "x",
allowSameBoundaryTurnover: false,
bufferDuration: "PT0S",
holdDuration: "PT0M",
capacityMode: "absolute",
};
const window = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
};
describe("detector", () => {
it("unknown mode raises BOOKING.UNKNOWN_CONFLICT_MODE", {
timeout: 5000,
}, () => {
clearDetectorRegistry();
try {
detectConflicts(window, [], { ...cfg, mode: "missing" });
} catch (err) {
expect(err).toBeInstanceOf(BookingError);
if (err instanceof BookingError) {
expect(err.code).toBe(BOOKING_CODES.UNKNOWN_CONFLICT_MODE);
expect(err.mode).toBe("missing");
return;
}
}
expect.fail("expected throw");
});
it("register/get/clear lifecycle", { timeout: 5000 }, () => {
clearDetectorRegistry();
expect(getRegisteredModes()).toEqual([]);
registerDetector("custom", () => ({
hasConflict: true,
conflictingWindowRefs: [],
suggestedAlternativeWindows: [],
}));
expect(getRegisteredModes()).toEqual(["custom"]);
const result = detectConflicts(window, [], { ...cfg, mode: "custom" });
expect(result.hasConflict).toBe(true);
clearDetectorRegistry();
expect(getRegisteredModes()).toEqual([]);
});
});

View File

@@ -0,0 +1,42 @@
/**
* Generic conflict detector. Dispatches to mode-specific detector strategies
* registered via the module-level registry.
*/
import { BOOKING_CODES, BookingError } from "../../error-codes/error-codes.js";
import type { ConflictDetectorConfig } from "../../types/config.types.js";
import type { TimeWindow } from "../../types/temporal.types.js";
import type { ConflictDetectorFn, OverlapResult } from "./detector.types.js";
const detectorRegistry = new Map<string, ConflictDetectorFn>();
export function registerDetector(
mode: string,
detector: ConflictDetectorFn
): void {
detectorRegistry.set(mode, detector);
}
export function detectConflicts(
proposed: TimeWindow,
existing: TimeWindow[],
config: ConflictDetectorConfig
): OverlapResult {
const detector = detectorRegistry.get(config.mode);
if (!detector) {
throw new BookingError(
BOOKING_CODES.UNKNOWN_CONFLICT_MODE,
`Unknown conflict detection mode: '${config.mode}'`,
{ mode: config.mode }
);
}
return detector(proposed, existing, config);
}
export function getRegisteredModes(): string[] {
return [...detectorRegistry.keys()];
}
export function clearDetectorRegistry(): void {
detectorRegistry.clear();
}

View File

@@ -0,0 +1,25 @@
/**
* Type definitions for the conflict detector pluggable registry.
*/
import type { ConflictDetectorConfig } from "../../types/config.types.js";
import type { TimeWindow } from "../../types/temporal.types.js";
export interface OverlapResult {
hasConflict: boolean;
conflictingWindowRefs: TimeWindow[];
suggestedAlternativeWindows: TimeWindow[];
}
export type ConflictDetectionMode =
| "exact_match"
| "range_overlap"
| "slot_capacity"
| "provider_overlap"
| "composite";
export type ConflictDetectorFn = (
proposed: TimeWindow,
existing: TimeWindow[],
config: ConflictDetectorConfig
) => OverlapResult;

View File

@@ -0,0 +1,11 @@
export {
clearDetectorRegistry,
detectConflicts,
getRegisteredModes,
registerDetector,
} from "./detector.js";
export type {
ConflictDetectionMode,
ConflictDetectorFn,
OverlapResult,
} from "./detector.types.js";

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,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { detectExactMatch } from "./exact-match.js";
const cfg = {
mode: "exact_match",
allowSameBoundaryTurnover: false,
bufferDuration: "PT0S",
holdDuration: "PT0M",
capacityMode: "absolute",
};
describe("exact-match", () => {
it("matches only on exact start+end", { timeout: 5000 }, () => {
const proposed = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
};
const existing = [
proposed,
{
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:30:00Z",
timezone: "UTC",
},
{
startTime: "2026-06-01T10:30:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
},
];
const result = detectExactMatch(proposed, existing, cfg);
expect(result.hasConflict).toBe(true);
expect(result.conflictingWindowRefs).toHaveLength(1);
});
it("returns no conflict when no exact match exists", {
timeout: 5000,
}, () => {
const proposed = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
};
const existing = [
{
startTime: "2026-06-01T09:00:00Z",
endTime: "2026-06-01T10:30:00Z",
timezone: "UTC",
},
];
expect(detectExactMatch(proposed, existing, cfg).hasConflict).toBe(
false
);
});
});

View File

@@ -0,0 +1,29 @@
/**
* Exact-match conflict detection for seat-based bookings. A conflict occurs
* when the same bookable is booked for the exact same time.
*/
import type { ConflictDetectorConfig } from "../../types/config.types.js";
import type { TimeWindow } from "../../types/temporal.types.js";
import type { OverlapResult } from "../detector/detector.types.js";
export function detectExactMatch(
proposed: TimeWindow,
existing: TimeWindow[],
_config: ConflictDetectorConfig
): OverlapResult {
const pStart = new Date(proposed.startTime).getTime();
const pEnd = new Date(proposed.endTime).getTime();
const conflicts = existing.filter((e) => {
const eStart = new Date(e.startTime).getTime();
const eEnd = new Date(e.endTime).getTime();
return pStart === eStart && pEnd === eEnd;
});
return {
hasConflict: conflicts.length > 0,
conflictingWindowRefs: conflicts,
suggestedAlternativeWindows: [],
};
}

View File

@@ -0,0 +1 @@
export { detectExactMatch } from "./exact-match.js";

View File

@@ -0,0 +1 @@
export { detectProviderOverlap } from "./provider-overlap.js";

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,37 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { detectProviderOverlap } from "./provider-overlap.js";
describe("provider-overlap", () => {
it("honours bufferDuration", { timeout: 5000 }, () => {
const proposed = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T10:30:00Z",
timezone: "UTC",
};
const existing = [
{
startTime: "2026-06-01T09:00:00Z",
endTime: "2026-06-01T09:50:00Z",
timezone: "UTC",
},
];
expect(
detectProviderOverlap(proposed, existing, {
mode: "provider_overlap",
allowSameBoundaryTurnover: false,
bufferDuration: "PT15M",
holdDuration: "PT5M",
capacityMode: "absolute",
}).hasConflict
).toBe(true);
expect(
detectProviderOverlap(proposed, existing, {
mode: "provider_overlap",
allowSameBoundaryTurnover: false,
bufferDuration: "PT0S",
holdDuration: "PT5M",
capacityMode: "absolute",
}).hasConflict
).toBe(false);
});
});

View File

@@ -0,0 +1,31 @@
/**
* Provider-overlap conflict detection for appointment bookings. A conflict
* occurs when a provider is double-booked (accounting for buffers).
*/
import type { ConflictDetectorConfig } from "../../types/config.types.js";
import type { TimeWindow } from "../../types/temporal.types.js";
import { parseDurationMs } from "../../utils/utils.js";
import type { OverlapResult } from "../detector/detector.types.js";
export function detectProviderOverlap(
proposed: TimeWindow,
existing: TimeWindow[],
config: ConflictDetectorConfig
): OverlapResult {
const bufferMs = parseDurationMs(config.bufferDuration);
const pStart = new Date(proposed.startTime).getTime();
const pEnd = new Date(proposed.endTime).getTime();
const conflicts = existing.filter((e) => {
const eStart = new Date(e.startTime).getTime() - bufferMs;
const eEnd = new Date(e.endTime).getTime() + bufferMs;
return pStart < eEnd && eStart < pEnd;
});
return {
hasConflict: conflicts.length > 0,
conflictingWindowRefs: conflicts,
suggestedAlternativeWindows: [],
};
}

View File

@@ -0,0 +1 @@
export { detectRangeOverlap } from "./range-overlap.js";

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,126 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { detectRangeOverlap } from "./range-overlap.js";
describe("range-overlap", () => {
it("detects overlap with buffer", { timeout: 5000 }, () => {
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",
},
];
const result = detectRangeOverlap(proposed, existing, {
mode: "range_overlap",
allowSameBoundaryTurnover: false,
bufferDuration: "PT4H",
holdDuration: "PT10M",
capacityMode: "absolute",
});
expect(result.hasConflict).toBe(true);
});
it("allowSameBoundaryTurnover excludes boundary-touching", {
timeout: 5000,
}, () => {
const proposed = {
startTime: "2026-06-03T11: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",
},
];
const result = detectRangeOverlap(proposed, existing, {
mode: "range_overlap",
allowSameBoundaryTurnover: true,
bufferDuration: "PT0S",
holdDuration: "PT10M",
capacityMode: "absolute",
});
expect(result.hasConflict).toBe(false);
});
it("respects boundary turnover with end-on-other-start", {
timeout: 5000,
}, () => {
const proposed = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T14:00:00Z",
timezone: "UTC",
};
const existing = [
{
startTime: "2026-06-01T14:00:00Z",
endTime: "2026-06-01T18:00:00Z",
timezone: "UTC",
},
];
const result = detectRangeOverlap(proposed, existing, {
mode: "range_overlap",
allowSameBoundaryTurnover: true,
bufferDuration: "PT0S",
holdDuration: "PT10M",
capacityMode: "absolute",
});
expect(result.hasConflict).toBe(false);
});
it("allowSameBoundaryTurnover with genuine overlap (not boundary) still flags conflict", {
timeout: 5000,
}, () => {
const proposed = {
startTime: "2026-06-02T08:00:00Z",
endTime: "2026-06-04T11:00:00Z",
timezone: "UTC",
};
const existing = [
{
startTime: "2026-06-01T14:00:00Z",
endTime: "2026-06-03T11:00:00Z",
timezone: "UTC",
},
];
const result = detectRangeOverlap(proposed, existing, {
mode: "range_overlap",
allowSameBoundaryTurnover: true,
bufferDuration: "PT0S",
holdDuration: "PT10M",
capacityMode: "absolute",
});
expect(result.hasConflict).toBe(true);
});
it("no overlap when windows are far apart", { timeout: 5000 }, () => {
const proposed = {
startTime: "2026-06-10T10:00:00Z",
endTime: "2026-06-11T10:00:00Z",
timezone: "UTC",
};
const existing = [
{
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-02T10:00:00Z",
timezone: "UTC",
},
];
expect(
detectRangeOverlap(proposed, existing, {
mode: "range_overlap",
allowSameBoundaryTurnover: false,
bufferDuration: "PT0S",
holdDuration: "PT10M",
capacityMode: "absolute",
}).hasConflict
).toBe(false);
});
});

View File

@@ -0,0 +1,41 @@
/**
* Range-overlap conflict detection for accommodation bookings. Uses half-open
* interval overlap formula with optional same-boundary turnover handling.
*/
import type { ConflictDetectorConfig } from "../../types/config.types.js";
import type { TimeWindow } from "../../types/temporal.types.js";
import { parseDurationMs } from "../../utils/utils.js";
import type { OverlapResult } from "../detector/detector.types.js";
export function detectRangeOverlap(
proposed: TimeWindow,
existing: TimeWindow[],
config: ConflictDetectorConfig
): OverlapResult {
const bufferMs = parseDurationMs(config.bufferDuration);
const pStart = new Date(proposed.startTime).getTime();
const pEnd = new Date(proposed.endTime).getTime();
const conflicts = existing.filter((e) => {
const eStart = new Date(e.startTime).getTime() - bufferMs;
const eEnd = new Date(e.endTime).getTime() + bufferMs;
const overlaps = pStart < eEnd && eStart < pEnd;
if (config.allowSameBoundaryTurnover) {
const eOrigStart = new Date(e.startTime).getTime();
const eOrigEnd = new Date(e.endTime).getTime();
const isBoundary = pStart === eOrigEnd || pEnd === eOrigStart;
return overlaps && !isBoundary;
}
return overlaps;
});
return {
hasConflict: conflicts.length > 0,
conflictingWindowRefs: conflicts,
suggestedAlternativeWindows: [],
};
}

View File

@@ -0,0 +1 @@
export { type CapacityContext, detectSlotCapacity } from "./slot-capacity.js";

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,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { detectSlotCapacity } from "./slot-capacity.js";
const cfg = {
mode: "slot_capacity",
allowSameBoundaryTurnover: false,
bufferDuration: "PT0S",
holdDuration: "PT0M",
capacityMode: "absolute",
};
const proposed = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
};
const overlapping = [proposed];
describe("slot-capacity", () => {
it("returns no conflict without context", { timeout: 5000 }, () => {
expect(detectSlotCapacity(proposed, overlapping, cfg).hasConflict).toBe(
false
);
});
it("reports conflict when total exceeds capacity", {
timeout: 5000,
}, () => {
const result = detectSlotCapacity(proposed, overlapping, cfg, {
totalCapacity: 10,
currentBookedQuantity: 8,
requestedQuantity: 5,
});
expect(result.hasConflict).toBe(true);
expect(result.conflictingWindowRefs.length).toBeGreaterThan(0);
});
it("reports no conflict when capacity remains", { timeout: 5000 }, () => {
const result = detectSlotCapacity(proposed, overlapping, cfg, {
totalCapacity: 100,
currentBookedQuantity: 5,
requestedQuantity: 5,
});
expect(result.hasConflict).toBe(false);
expect(result.conflictingWindowRefs).toEqual([]);
});
});

View File

@@ -0,0 +1,48 @@
/**
* Slot-capacity conflict detection for group bookings. A conflict occurs when
* the total booked quantity would exceed the slot's capacity.
*/
import type { ConflictDetectorConfig } from "../../types/config.types.js";
import type { TimeWindow } from "../../types/temporal.types.js";
import type { OverlapResult } from "../detector/detector.types.js";
export interface CapacityContext {
totalCapacity: number;
currentBookedQuantity: number;
requestedQuantity: number;
}
export function detectSlotCapacity(
proposed: TimeWindow,
existing: TimeWindow[],
_config: ConflictDetectorConfig,
context?: CapacityContext
): OverlapResult {
const pStart = new Date(proposed.startTime).getTime();
const pEnd = new Date(proposed.endTime).getTime();
const overlapping = existing.filter((e) => {
const eStart = new Date(e.startTime).getTime();
const eEnd = new Date(e.endTime).getTime();
return pStart < eEnd && eStart < pEnd;
});
if (!context) {
return {
hasConflict: false,
conflictingWindowRefs: [],
suggestedAlternativeWindows: [],
};
}
const totalAfterBooking =
context.currentBookedQuantity + context.requestedQuantity;
const hasConflict = totalAfterBooking > context.totalCapacity;
return {
hasConflict,
conflictingWindowRefs: hasConflict ? overlapping : [],
suggestedAlternativeWindows: [],
};
}

View File

@@ -0,0 +1,104 @@
/**
* Spec-conformance tests: pin barrel + subpath + tier-purity invariants
* declared in `openspec/changes/add-booking/specs/booking/spec.md`.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = resolve(__dirname, "..");
const SRC_ROOT = join(PKG_ROOT, "src");
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const stat = statSync(p);
if (stat.isDirectory()) walk(p, out);
else out.push(p);
}
return out;
}
describe("conformance", () => {
it("every src/<feature>/index.ts uses explicit named exports", {
timeout: 5000,
}, () => {
const offenders: string[] = [];
const files = walk(SRC_ROOT).filter(
(f) =>
f.endsWith("/index.ts") &&
relative(SRC_ROOT, f) !== "index.ts" &&
relative(SRC_ROOT, f) !== "constants/index.ts"
);
expect(files.length).toBeGreaterThan(0);
for (const file of files) {
const text = readFileSync(file, "utf-8");
// Match `export * from` or `export type * from` (excluding namespace exports `export * as X`)
if (/^\s*export\s+(type\s+)?\*\s+from/m.test(text)) {
offenders.push(relative(SRC_ROOT, file));
}
}
expect(offenders).toEqual([]);
});
it("every public subpath is declared in package.json exports", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf-8")
) as { exports: Record<string, unknown> };
const subpaths = Object.keys(pkg.exports).filter(
(k) => k.startsWith("./") && k !== "./package.json"
);
// At least every documented feature module — the spec calls for 29 subpaths
expect(subpaths.length).toBeGreaterThanOrEqual(20);
// Each subpath maps to a file that will exist after build (we check the dist path's source counterpart)
for (const subpath of subpaths) {
if (subpath === ".") continue;
const featurePath = subpath.replace(/^\.\//, "");
const srcFile = join(SRC_ROOT, `${featurePath}.ts`);
expect(() => statSync(srcFile)).not.toThrow();
}
});
it("src tree contains no browser/Node-only globals", {
timeout: 5000,
}, () => {
// Detect access on the global `window`/`document`/`navigator` objects
// without flagging local parameters or properties named `window`.
const banned = [
/\bdocument\.(getElementById|querySelector|createElement|cookie|body|location|head)/,
/\b(globalThis|self)\.(document|location|navigator|localStorage|sessionStorage)/,
/\bnavigator\.(userAgent|language|platform|geolocation|clipboard|serviceWorker)/,
/from\s+["']node:/,
/require\(\s*["']node:/,
];
const offenders: string[] = [];
const files = walk(SRC_ROOT).filter(
(f) =>
f.endsWith(".ts") &&
!f.endsWith(".test.ts") &&
!f.endsWith(".spec.ts")
);
for (const file of files) {
const text = readFileSync(file, "utf-8");
for (const pattern of banned) {
if (pattern.test(text)) {
offenders.push(
`${relative(SRC_ROOT, file)} — matches ${pattern}`
);
break;
}
}
}
expect(offenders).toEqual([]);
});
it("dirname keeps the conformance test inside src/", {
timeout: 5000,
}, () => {
// Anchor sanity-check: the test file must be co-located in src/
expect(dirname(__filename).endsWith("/src")).toBe(true);
});
});

View File

@@ -0,0 +1,20 @@
/**
* Default configuration constants for the booking system. All values are
* overridable via JSON configuration.
*/
export const DEFAULT_HOLD_DURATION_MINUTES = 10;
export const DEFAULT_MAX_HOLD_DURATION_MINUTES = 30;
export const DEFAULT_MAX_ADVANCE_DAYS = 90;
export const DEFAULT_SCHEMA_VERSION = "1.0.0";
export const DEFAULT_CURRENCY = "USD";
export const DEFAULT_TAX_STRATEGY = "exclusive";
export const DEFAULT_ROUNDING_MODE = "round";
export const DEFAULT_ROUNDING_PRECISION = 2;
export const DEFAULT_AVAILABILITY_REFRESH_MS = 30000;
export const DEFAULT_CACHE_TTL_MS = 60000;
export const DEFAULT_SLOT_DURATION_MS = 3600000;
export const DEFAULT_LOCALE = "en-US";
export const DEFAULT_DATE_FORMAT = "MM/dd/yyyy";
export const DEFAULT_TIME_FORMAT = "12h";
export const DEFAULT_FIRST_DAY_OF_WEEK = 0;

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,441 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createSeatConfig } from "../../domains/seats/seats.js";
import { BOOKING_CODES, BookingError } from "../../error-codes/error-codes.js";
import type { Bookable } from "../../types/bookable.types.js";
import type { BookingSystemConfig } from "../../types/config.types.js";
import { createBookingEngine } from "./engine.js";
function makeBookable(id = "b1", tenantId = "t1"): Bookable {
return {
identifierRef: { id, tenantId, categoryId: "cat" },
name: id,
description: "",
bookableType: "seat",
status: "active",
layoutNodeRef: null,
capacityRef: "cap",
priceRuleRefs: ["rate-1"],
timeSlotRefs: [],
attributes: {},
};
}
function configureWith(): BookingSystemConfig {
const cfg = createSeatConfig("t1", "T1");
return {
...cfg,
priceRules: [
{
ruleId: "rate-1",
amount: 50,
currency: "USD",
pricingModel: "fixed",
unitDuration: "",
},
],
};
}
const window = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
};
const window2 = {
startTime: "2026-06-02T10:00:00Z",
endTime: "2026-06-02T11:00:00Z",
timezone: "UTC",
};
describe("BookingEngine", () => {
it("orchestration before configure raises BOOKING.ENGINE_NOT_CONFIGURED", {
timeout: 5000,
}, () => {
const engine = createBookingEngine();
try {
engine.registerBookable(makeBookable());
} catch (err) {
expect(err).toBeInstanceOf(BookingError);
if (err instanceof BookingError) {
expect(err.code).toBe(BOOKING_CODES.ENGINE_NOT_CONFIGURED);
return;
}
}
expect.fail("expected throw");
});
it("confirmBooking surfaces conflict as BOOKING.BOOKING_CONFLICT", {
timeout: 5000,
}, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
engine.registerBookable(makeBookable());
engine.confirmBooking({
bookableRef: "b1",
customerRef: "c1",
windowRef: window,
quantity: 1,
});
try {
engine.confirmBooking({
bookableRef: "b1",
customerRef: "c2",
windowRef: window,
quantity: 1,
});
} catch (err) {
expect(err).toBeInstanceOf(BookingError);
if (err instanceof BookingError) {
expect(err.code).toBe(BOOKING_CODES.BOOKING_CONFLICT);
return;
}
}
expect.fail("expected throw");
});
it("cancelBooking raises BOOKING.BOOKING_NOT_FOUND for missing id", {
timeout: 5000,
}, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
try {
engine.cancelBooking("nope", "x");
} catch (err) {
expect(err).toBeInstanceOf(BookingError);
if (err instanceof BookingError) {
expect(err.code).toBe(BOOKING_CODES.BOOKING_NOT_FOUND);
return;
}
}
expect.fail("expected throw");
});
it("cancelBooking enforces state machine and raises BOOKING.TRANSITION_NOT_ALLOWED", {
timeout: 5000,
}, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
engine.registerBookable(makeBookable());
const b = engine.confirmBooking({
bookableRef: "b1",
customerRef: "c1",
windowRef: window,
quantity: 1,
});
engine.cancelBooking(b.identifierRef.id, "reason");
try {
engine.cancelBooking(b.identifierRef.id, "again");
} catch (err) {
expect(err).toBeInstanceOf(BookingError);
if (err instanceof BookingError) {
expect(err.code).toBe(BOOKING_CODES.TRANSITION_NOT_ALLOWED);
return;
}
}
expect.fail("expected throw");
});
it("hook transforms reach the persisted booking", { timeout: 5000 }, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
engine.registerBookable(makeBookable());
engine.bookingHooks.hook("booking:creating", (data) => ({
...data,
customerRef: data.customerRef.toUpperCase(),
notes: "hook-set",
}));
const b = engine.confirmBooking({
bookableRef: "b1",
customerRef: "c1",
windowRef: window,
quantity: 1,
});
expect(b.customerRef).toBe("C1");
expect(b.notes).toBe("hook-set");
});
it("hold and booking lifecycle emit events", { timeout: 5000 }, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
engine.registerBookable(makeBookable());
const seen: string[] = [];
engine.on("hold:created", (e) => seen.push(e.eventType));
engine.on("booking:confirmed", (e) => seen.push(e.eventType));
engine.on("booking:cancelled", (e) => seen.push(e.eventType));
engine.on("booking:modified", (e) => seen.push(e.eventType));
engine.on("waitlist:joined", (e) => seen.push(e.eventType));
const hold = engine.createHold({
bookableRef: "b1",
customerRef: "c1",
windowRef: window,
quantity: 1,
});
expect(engine.getHold(hold.holdId)).not.toBeNull();
const b = engine.confirmBooking({
bookableRef: "b1",
customerRef: "c1",
windowRef: window,
quantity: 1,
holdId: hold.holdId,
});
// Hold should have been deleted by confirm
expect(engine.getHold(hold.holdId)).toBeNull();
const modified = engine.modifyBooking(b.identifierRef.id, {
quantity: 2,
notes: "n",
windowRef: window2,
});
expect(modified.quantity).toBe(2);
expect(modified.notes).toBe("n");
expect(engine.getBooking(b.identifierRef.id)?.windowRef.startTime).toBe(
window2.startTime
);
engine.joinWaitlist({
bookableRef: "b1",
customerRef: "c2",
requestedWindowRef: window,
});
const cancellation = engine.cancelBooking(b.identifierRef.id, "reason");
expect(cancellation.status).toBe("cancelled");
expect(cancellation.bookingId).toBe(b.identifierRef.id);
expect(seen).toEqual([
"hold:created",
"booking:confirmed",
"booking:modified",
"waitlist:joined",
"booking:cancelled",
]);
});
it("modifyBooking raises BOOKING_NOT_FOUND for missing id", {
timeout: 5000,
}, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
expect(() =>
engine.modifyBooking("nope", { quantity: 5 })
).toThrowError(BookingError);
});
it("checkAvailability/getAvailableSlots/listBookables behave as documented", {
timeout: 5000,
}, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
engine.registerBookable(makeBookable("b1"));
engine.registerBookable(makeBookable("b2"));
// listBookables — filters honor tenant, type, status, category
const list = engine.listBookables({
tenantId: "t1",
limit: 1,
offset: 0,
});
expect(list.total).toBe(2);
expect(list.items).toHaveLength(1);
expect(
engine.listBookables({ tenantId: "t1", bookableType: "seat" }).total
).toBe(2);
expect(
engine.listBookables({ tenantId: "t1", status: "active" }).total
).toBe(2);
expect(
engine.listBookables({ tenantId: "t1", categoryId: "cat" }).total
).toBe(2);
expect(
engine.listBookables({ tenantId: "t1", categoryId: "nope" }).total
).toBe(0);
expect(engine.listBookables({ tenantId: "other" }).total).toBe(0);
// checkAvailability — unknown bookable returns unavailable
expect(
engine.checkAvailability({
bookableRef: "missing",
windowRef: window,
quantity: 1,
}).available
).toBe(false);
// checkAvailability — available when nothing overlaps
expect(
engine.checkAvailability({
bookableRef: "b1",
windowRef: window,
quantity: 1,
}).available
).toBe(true);
// getAvailableSlots — emits one-hour slots
const slots = engine.getAvailableSlots("b1", {
startTime: "2026-06-01T09:00:00Z",
endTime: "2026-06-01T12:00:00Z",
timezone: "UTC",
});
expect(slots.length).toBe(3);
// getAvailableSlots — unknown bookable returns empty
expect(engine.getAvailableSlots("nope", window)).toEqual([]);
// getBookable
expect(engine.getBookable("b1")?.name).toBe("b1");
expect(engine.getBookable("nope")).toBeNull();
expect(engine.getBooking("nope")).toBeNull();
});
it("calculatePrice rounds per pricingRef config", { timeout: 5000 }, () => {
const engine = createBookingEngine();
const cfg = configureWith();
engine.configure({
...cfg,
pricingRef: { ...cfg.pricingRef, roundingMode: "ceil" },
});
engine.registerBookable(makeBookable());
const price = engine.calculatePrice({
bookableRef: "b1",
windowRef: window,
quantity: 3,
});
expect(price.subtotal).toBe(150);
expect(price.tax).toBe(15);
expect(price.total).toBe(165);
expect(price.currency).toBe("USD");
expect(price.breakdown).toEqual(["rate-1"]);
// With no priceRules attached to the bookable, subtotal is 0
const cleanBookable = { ...makeBookable("b2"), priceRuleRefs: [] };
engine.registerBookable(cleanBookable);
const empty = engine.calculatePrice({
bookableRef: "b2",
windowRef: window,
quantity: 1,
});
expect(empty.subtotal).toBe(0);
// Inclusive tax → 0 tax
const engine2 = createBookingEngine();
engine2.configure({
...cfg,
pricingRef: {
...cfg.pricingRef,
taxStrategy: "inclusive",
roundingMode: "floor",
},
});
engine2.registerBookable(makeBookable());
const p2 = engine2.calculatePrice({
bookableRef: "b1",
windowRef: window,
quantity: 1,
});
expect(p2.tax).toBe(0);
expect(p2.total).toBe(50);
});
it("joinWaitlist appends entries", { timeout: 5000 }, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
const entry = engine.joinWaitlist({
bookableRef: "b1",
customerRef: "c1",
requestedWindowRef: window,
});
expect(entry.bookableRef).toBe("b1");
expect(entry.priority).toBe(0);
const entry2 = engine.joinWaitlist({
bookableRef: "b1",
customerRef: "c2",
requestedWindowRef: window,
priority: 5,
});
expect(entry2.priority).toBe(5);
});
it("off forwards to underlying event bus", { timeout: 5000 }, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
const id = engine.on("hold:created", () => {});
engine.off("hold:created", id);
// No assertion needed beyond the call not throwing
expect(typeof id).toBe("string");
});
it("confirmBooking with unknown bookable surfaces BOOKING_CONFLICT without a bookingId discriminant", {
timeout: 5000,
}, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
try {
engine.confirmBooking({
bookableRef: "missing",
customerRef: "c",
windowRef: window,
quantity: 1,
});
} catch (err) {
expect(err).toBeInstanceOf(BookingError);
if (err instanceof BookingError) {
expect(err.code).toBe(BOOKING_CODES.BOOKING_CONFLICT);
expect(err.bookingId).toBeUndefined();
return;
}
}
expect.fail("expected throw");
});
it("getAvailableSlots skips slots that overlap existing bookings", {
timeout: 5000,
}, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
engine.registerBookable(makeBookable());
engine.confirmBooking({
bookableRef: "b1",
customerRef: "c",
windowRef: {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
},
quantity: 1,
});
const slots = engine.getAvailableSlots("b1", {
startTime: "2026-06-01T09:00:00Z",
endTime: "2026-06-01T12:00:00Z",
timezone: "UTC",
});
// 3-hour range, 1 slot blocked → 2 slots remain
expect(slots).toHaveLength(2);
});
it("modifyBooking applies only the supplied fields", {
timeout: 5000,
}, () => {
const engine = createBookingEngine();
engine.configure(configureWith());
engine.registerBookable(makeBookable());
const b = engine.confirmBooking({
bookableRef: "b1",
customerRef: "c",
windowRef: window,
quantity: 1,
});
const m1 = engine.modifyBooking(b.identifierRef.id, { quantity: 4 });
expect(m1.quantity).toBe(4);
expect(m1.windowRef).toEqual(window);
const m2 = engine.modifyBooking(b.identifierRef.id, { notes: "only" });
expect(m2.notes).toBe("only");
const m3 = engine.modifyBooking(b.identifierRef.id, {
windowRef: window2,
});
expect(m3.windowRef).toEqual(window2);
const m4 = engine.modifyBooking(b.identifierRef.id, {});
expect(m4.quantity).toBe(4);
});
});

View File

@@ -0,0 +1,455 @@
/**
* Core booking engine implementation. Orchestrates availability, conflict
* detection, pricing, hooks, state machine, and event bus.
*/
import { BOOKING_CODES, BookingError } from "../../error-codes/error-codes.js";
import { Hooks } from "../../hooks/hooks.js";
import type {
Bookable,
BookableQuery,
BookableQueryResult,
} from "../../types/bookable.types.js";
import type {
Booking,
BookingModification,
BookingRequest,
CancellationResult,
HoldRequest,
WaitlistRequest,
} from "../../types/booking.types.js";
import type { BookingHookMap } from "../../types/booking-hooks.types.js";
import type { BookingHold, WaitlistEntry } from "../../types/capacity.types.js";
import type { BookingSystemConfig } from "../../types/config.types.js";
import type {
BookingEventHandler,
BookingEventType,
} from "../../types/events.types.js";
import type { BookingPrice } from "../../types/pricing.types.js";
import type { TimeSlot, TimeWindow } from "../../types/temporal.types.js";
import { EventBus } from "../event-bus/event-bus.js";
import { StateMachine } from "../state-machine/state-machine.js";
import type {
AvailabilityQuery,
AvailabilityResult,
BookingEngineInterface,
PriceCalculationRequest,
} from "./engine.types.js";
let idCounter = 0;
function generateId(prefix: string): string {
idCounter += 1;
return `${prefix}_${idCounter.toString(36)}_${Date.now().toString(36)}`;
}
function windowsOverlap(a: TimeWindow, b: TimeWindow): boolean {
const aStart = new Date(a.startTime).getTime();
const aEnd = new Date(a.endTime).getTime();
const bStart = new Date(b.startTime).getTime();
const bEnd = new Date(b.endTime).getTime();
return aStart < bEnd && bStart < aEnd;
}
function roundPrice(value: number, precision: number, mode: string): number {
const factor = 10 ** precision;
switch (mode) {
case "floor":
return Math.floor(value * factor) / factor;
case "ceil":
return Math.ceil(value * factor) / factor;
default:
return Math.round(value * factor) / factor;
}
}
export class BookingEngine<
TBookableMeta extends Record<string, unknown> = Record<string, unknown>,
TBookingMeta extends Record<string, unknown> = Record<string, unknown>,
> implements BookingEngineInterface<TBookableMeta, TBookingMeta>
{
private config: BookingSystemConfig | null = null;
private readonly bookables = new Map<string, Bookable<TBookableMeta>>();
private readonly bookings = new Map<string, Booking<TBookingMeta>>();
private readonly holds = new Map<string, BookingHold>();
private readonly waitlist: WaitlistEntry[] = [];
private readonly eventBus = new EventBus();
private readonly _hooks = new Hooks<BookingHookMap>();
private stateMachine: StateMachine | null = null;
public get bookingHooks(): Hooks<BookingHookMap> {
return this._hooks;
}
public configure(config: BookingSystemConfig): void {
this.config = config;
this.stateMachine = new StateMachine(config.statusTransitions);
}
private ensureConfigured(): BookingSystemConfig {
if (!this.config) {
throw new BookingError(
BOOKING_CODES.ENGINE_NOT_CONFIGURED,
"BookingEngine not configured. Call configure() first."
);
}
return this.config;
}
public registerBookable(bookable: Bookable<TBookableMeta>): void {
this.ensureConfigured();
this.bookables.set(bookable.identifierRef.id, bookable);
}
public getBookable(id: string): Bookable<TBookableMeta> | null {
return this.bookables.get(id) ?? null;
}
public listBookables(
query: BookableQuery
): BookableQueryResult<TBookableMeta> {
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
let items = [...this.bookables.values()].filter(
(b) => b.identifierRef.tenantId === query.tenantId
);
if (query.bookableType) {
items = items.filter((b) => b.bookableType === query.bookableType);
}
if (query.status) {
items = items.filter((b) => b.status === query.status);
}
if (query.categoryId) {
items = items.filter(
(b) => b.identifierRef.categoryId === query.categoryId
);
}
const total = items.length;
const paged = items.slice(offset, offset + limit);
return { items: paged, total, limit, offset };
}
public checkAvailability(query: AvailabilityQuery): AvailabilityResult {
this.ensureConfigured();
const bookable = this.bookables.get(query.bookableRef);
if (!bookable) {
return {
available: false,
remainingCapacity: 0,
conflictingBookings: [],
suggestedAlternatives: [],
};
}
const existing = this.getBookingsForBookable(query.bookableRef);
const conflicting = existing.filter((b) =>
windowsOverlap(b.windowRef, query.windowRef)
);
return {
available: conflicting.length === 0,
remainingCapacity: Math.max(0, 1 - conflicting.length),
conflictingBookings: conflicting.map((b) => b.identifierRef.id),
suggestedAlternatives: [],
};
}
public getAvailableSlots(
bookableId: string,
window: TimeWindow
): TimeSlot[] {
this.ensureConfigured();
const bookable = this.bookables.get(bookableId);
if (!bookable) return [];
const existing = this.getBookingsForBookable(bookableId);
const slots: TimeSlot[] = [];
const slotDuration = 3600000;
let current = new Date(window.startTime).getTime();
const end = new Date(window.endTime).getTime();
while (current < end) {
const slotStart = new Date(current).toISOString();
const slotEnd = new Date(current + slotDuration).toISOString();
const slotWindow: TimeWindow = {
startTime: slotStart,
endTime: slotEnd,
timezone: window.timezone,
};
const hasConflict = existing.some((b) =>
windowsOverlap(b.windowRef, slotWindow)
);
if (!hasConflict) {
slots.push({
slotId: generateId("slot"),
windowRef: slotWindow,
recurrenceRef: null,
capacityRemaining: 1,
});
}
current += slotDuration;
}
return slots;
}
public createHold(request: HoldRequest): BookingHold {
const config = this.ensureConfigured();
const holdDurationMs =
config.holdRef.defaultDurationMinutes * 60 * 1000;
const hold: BookingHold = {
holdId: generateId("hold"),
bookableRef: request.bookableRef,
customerRef: request.customerRef,
windowRef: request.windowRef,
expiresAt: new Date(Date.now() + holdDurationMs).toISOString(),
quantity: request.quantity,
};
this.holds.set(hold.holdId, hold);
this.eventBus.emit("hold:created", config.tenantRef.tenantId, hold);
return hold;
}
public confirmBooking(
request: BookingRequest<TBookingMeta>
): Booking<TBookingMeta> {
const config = this.ensureConfigured();
const hookData = this._hooks.applyHooks("booking:creating", {
bookableRef: request.bookableRef,
customerRef: request.customerRef,
windowRef: request.windowRef,
quantity: request.quantity,
notes: request.notes ?? "",
metadata: (request.metadata ?? {}) as Record<string, unknown>,
});
if (request.holdId) {
this.holds.delete(request.holdId);
}
const availability = this.checkAvailability({
bookableRef: hookData.bookableRef,
windowRef: hookData.windowRef,
quantity: hookData.quantity,
});
if (!availability.available) {
const firstConflict = availability.conflictingBookings[0];
throw new BookingError(
BOOKING_CODES.BOOKING_CONFLICT,
`Booking conflict: ${availability.conflictingBookings.join(", ")}`,
firstConflict !== undefined ? { bookingId: firstConflict } : {}
);
}
const price = this.calculatePrice({
bookableRef: hookData.bookableRef,
windowRef: hookData.windowRef,
quantity: hookData.quantity,
});
const booking: Booking<TBookingMeta> = {
identifierRef: {
id: generateId("bkg"),
tenantId: config.tenantRef.tenantId,
createdAt: new Date().toISOString(),
},
bookableRef: hookData.bookableRef,
customerRef: hookData.customerRef,
windowRef: hookData.windowRef,
status: "confirmed",
quantity: hookData.quantity,
totalPriceRef: price,
holdExpiresAt: "",
cancellationRef: {
refundable: true,
deadlineBeforeStart: "PT24H",
penaltyCalculation: "none",
penaltyValue: 0,
},
notes: hookData.notes,
metadata: hookData.metadata as TBookingMeta,
};
this.bookings.set(booking.identifierRef.id, booking);
this.eventBus.emit(
"booking:confirmed",
config.tenantRef.tenantId,
booking
);
return booking;
}
public cancelBooking(
bookingId: string,
reason: string
): CancellationResult {
const config = this.ensureConfigured();
const booking = this.bookings.get(bookingId);
if (!booking) {
throw new BookingError(
BOOKING_CODES.BOOKING_NOT_FOUND,
`Booking '${bookingId}' not found`,
{ bookingId }
);
}
const cancelHookData = this._hooks.applyHooks("booking:cancelling", {
bookingId,
reason,
currentStatus: booking.status,
});
// `configure` always sets `stateMachine`; `ensureConfigured` ran above.
const sm = this.stateMachine as StateMachine;
const result = sm.canTransition({
from: booking.status,
to: "cancelled",
role: "customer",
reason: cancelHookData.reason,
});
if (!result.allowed) {
throw new BookingError(
BOOKING_CODES.TRANSITION_NOT_ALLOWED,
result.message,
{ bookingId }
);
}
booking.status = "cancelled";
this.bookings.set(bookingId, booking);
this.eventBus.emit("booking:cancelled", config.tenantRef.tenantId, {
bookingId,
reason: cancelHookData.reason,
});
return {
bookingId,
refundAmount: booking.totalPriceRef.total,
penaltyAmount: 0,
status: "cancelled",
};
}
public modifyBooking(
bookingId: string,
changes: BookingModification
): Booking<TBookingMeta> {
const config = this.ensureConfigured();
const booking = this.bookings.get(bookingId);
if (!booking) {
throw new BookingError(
BOOKING_CODES.BOOKING_NOT_FOUND,
`Booking '${bookingId}' not found`,
{ bookingId }
);
}
if (changes.windowRef) booking.windowRef = changes.windowRef;
if (changes.quantity !== undefined) {
booking.quantity = changes.quantity;
}
if (changes.notes !== undefined) booking.notes = changes.notes;
booking.totalPriceRef = this.calculatePrice({
bookableRef: booking.bookableRef,
windowRef: booking.windowRef,
quantity: booking.quantity,
});
this.bookings.set(bookingId, booking);
this.eventBus.emit(
"booking:modified",
config.tenantRef.tenantId,
booking
);
return booking;
}
public calculatePrice(request: PriceCalculationRequest): BookingPrice {
const config = this.ensureConfigured();
const bookable = this.bookables.get(request.bookableRef);
const rules = config.priceRules.filter((r) =>
bookable?.priceRuleRefs.includes(r.ruleId)
);
let subtotal = 0;
for (const rule of rules) {
subtotal += rule.amount * request.quantity;
}
if (rules.length === 0) subtotal = 0;
const taxRate = config.pricingRef.taxStrategy === "inclusive" ? 0 : 0.1;
const tax = roundPrice(
subtotal * taxRate,
config.pricingRef.roundingPrecision,
config.pricingRef.roundingMode
);
const total = roundPrice(
subtotal + tax,
config.pricingRef.roundingPrecision,
config.pricingRef.roundingMode
);
return {
subtotal,
tax,
fees: 0,
discount: 0,
total,
currency: config.pricingRef.defaultCurrency,
breakdown: rules.map((r) => r.ruleId),
};
}
public joinWaitlist(request: WaitlistRequest): WaitlistEntry {
const config = this.ensureConfigured();
const entry: WaitlistEntry = {
entryId: generateId("wl"),
bookableRef: request.bookableRef,
customerRef: request.customerRef,
requestedWindowRef: request.requestedWindowRef,
priority: request.priority ?? 0,
createdAt: new Date().toISOString(),
notifiedAt: null,
};
this.waitlist.push(entry);
this.eventBus.emit("waitlist:joined", config.tenantRef.tenantId, entry);
return entry;
}
public on(event: string, handler: BookingEventHandler): string {
return this.eventBus.on(event as BookingEventType, handler);
}
public off(event: string, handler: BookingEventHandler | string): void {
this.eventBus.off(event as BookingEventType, handler);
}
public getBooking(id: string): Booking<TBookingMeta> | null {
return this.bookings.get(id) ?? null;
}
public getHold(id: string): BookingHold | null {
return this.holds.get(id) ?? null;
}
private getBookingsForBookable(
bookableRef: string
): Booking<TBookingMeta>[] {
return [...this.bookings.values()].filter(
(b) => b.bookableRef === bookableRef && b.status !== "cancelled"
);
}
}
export function createBookingEngine<
TBookableMeta extends Record<string, unknown> = Record<string, unknown>,
TBookingMeta extends Record<string, unknown> = Record<string, unknown>,
>(): BookingEngine<TBookableMeta, TBookingMeta> {
return new BookingEngine<TBookableMeta, TBookingMeta>();
}

View File

@@ -0,0 +1,73 @@
/**
* Engine-specific type definitions.
*/
import type {
Bookable,
BookableQuery,
BookableQueryResult,
} from "../../types/bookable.types.js";
import type {
Booking,
BookingModification,
BookingRequest,
CancellationResult,
HoldRequest,
WaitlistRequest,
} from "../../types/booking.types.js";
import type { BookingHold, WaitlistEntry } from "../../types/capacity.types.js";
import type { BookingSystemConfig } from "../../types/config.types.js";
import type { BookingEventHandler } from "../../types/events.types.js";
import type { BookingPrice } from "../../types/pricing.types.js";
import type { TimeSlot, TimeWindow } from "../../types/temporal.types.js";
export interface AvailabilityQuery {
bookableRef: string;
windowRef: TimeWindow;
quantity: number;
}
export interface AvailabilityResult {
available: boolean;
remainingCapacity: number;
conflictingBookings: string[];
suggestedAlternatives: TimeWindow[];
}
export interface PriceCalculationRequest {
bookableRef: string;
windowRef: TimeWindow;
quantity: number;
}
export interface ConflictResult {
hasConflict: boolean;
conflictingBookingIds: string[];
conflictingHoldIds: string[];
message: string;
}
export interface BookingEngineInterface<
TBookableMeta extends Record<string, unknown> = Record<string, unknown>,
TBookingMeta extends Record<string, unknown> = Record<string, unknown>,
> {
configure(config: BookingSystemConfig): void;
registerBookable(bookable: Bookable<TBookableMeta>): void;
getBookable(id: string): Bookable<TBookableMeta> | null;
listBookables(query: BookableQuery): BookableQueryResult<TBookableMeta>;
checkAvailability(query: AvailabilityQuery): AvailabilityResult;
getAvailableSlots(bookableId: string, window: TimeWindow): TimeSlot[];
createHold(request: HoldRequest): BookingHold;
confirmBooking(
request: BookingRequest<TBookingMeta>
): Booking<TBookingMeta>;
cancelBooking(bookingId: string, reason: string): CancellationResult;
modifyBooking(
bookingId: string,
changes: BookingModification
): Booking<TBookingMeta>;
calculatePrice(request: PriceCalculationRequest): BookingPrice;
joinWaitlist(request: WaitlistRequest): WaitlistEntry;
on(event: string, handler: BookingEventHandler): string;
off(event: string, handler: BookingEventHandler | string): void;
}

View File

@@ -0,0 +1,8 @@
export { BookingEngine, createBookingEngine } from "./engine.js";
export type {
AvailabilityQuery,
AvailabilityResult,
BookingEngineInterface,
ConflictResult,
PriceCalculationRequest,
} from "./engine.types.js";

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,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createEventBus, EventBus } from "./event-bus.js";
describe("EventBus", () => {
it("emit wraps payload into BookingEvent", { timeout: 5000 }, () => {
const bus = createEventBus();
let captured: unknown;
bus.on("booking:confirmed", (event) => {
captured = event;
});
bus.emit("booking:confirmed", "t1", { ok: true });
expect(captured).toMatchObject({
eventType: "booking:confirmed",
tenantId: "t1",
payload: { ok: true },
});
});
it("off by id removes the subscription", { timeout: 5000 }, () => {
const bus = new EventBus();
let count = 0;
const id = bus.on("hold:created", () => {
count++;
});
bus.emit("hold:created", "t", {});
bus.off("hold:created", id);
bus.emit("hold:created", "t", {});
expect(count).toBe(1);
expect(bus.listenerCount("hold:created")).toBe(0);
});
it("off by handler removes the subscription", { timeout: 5000 }, () => {
const bus = new EventBus();
const handler = () => {};
bus.on("hold:created", handler);
bus.off("hold:created", handler);
expect(bus.listenerCount("hold:created")).toBe(0);
});
it("off on unknown event is a no-op", { timeout: 5000 }, () => {
const bus = new EventBus();
bus.off("hold:created", "missing");
expect(bus.listenerCount("hold:created")).toBe(0);
});
it("clear drops every subscription", { timeout: 5000 }, () => {
const bus = new EventBus();
bus.on("hold:created", () => {});
bus.on("booking:confirmed", () => {});
bus.clear();
expect(bus.listenerCount("hold:created")).toBe(0);
expect(bus.listenerCount("booking:confirmed")).toBe(0);
});
it("listenerCount and eventTypes report registry state", {
timeout: 5000,
}, () => {
const bus = new EventBus();
bus.on("hold:created", () => {});
bus.on("hold:created", () => {});
bus.on("booking:cancelled", () => {});
expect(bus.listenerCount("hold:created")).toBe(2);
expect(bus.listenerCount("booking:confirmed")).toBe(0);
expect(bus.eventTypes().sort()).toEqual([
"booking:cancelled",
"hold:created",
]);
});
it("off retains other handlers in same event", { timeout: 5000 }, () => {
const bus = new EventBus();
const h1 = () => {};
const h2 = () => {};
bus.on("hold:created", h1);
bus.on("hold:created", h2);
bus.off("hold:created", h1);
expect(bus.listenerCount("hold:created")).toBe(1);
});
});

View File

@@ -0,0 +1,96 @@
/**
* Typed event bus for the booking system. Provides publish/subscribe
* functionality for booking lifecycle events.
*/
import type {
BookingEvent,
BookingEventHandler,
BookingEventType,
EventSubscription,
} from "../../types/events.types.js";
let subscriptionCounter = 0;
function generateSubscriptionId(): string {
subscriptionCounter += 1;
return `sub_${subscriptionCounter.toString(36)}_${Date.now().toString(36)}`;
}
export class EventBus {
private readonly subscriptions: Map<BookingEventType, EventSubscription[]> =
new Map();
public on<TPayload = unknown>(
eventType: BookingEventType,
handler: BookingEventHandler<TPayload>
): string {
const id = generateSubscriptionId();
const subscription: EventSubscription = {
eventType,
handler: handler as BookingEventHandler,
id,
};
const existing = this.subscriptions.get(eventType) ?? [];
existing.push(subscription);
this.subscriptions.set(eventType, existing);
return id;
}
public off(
eventType: BookingEventType,
handlerOrId: BookingEventHandler | string
): void {
const existing = this.subscriptions.get(eventType);
if (!existing) return;
const filtered = existing.filter((sub) => {
if (typeof handlerOrId === "string") {
return sub.id !== handlerOrId;
}
return sub.handler !== handlerOrId;
});
if (filtered.length === 0) {
this.subscriptions.delete(eventType);
} else {
this.subscriptions.set(eventType, filtered);
}
}
public emit<TPayload = unknown>(
eventType: BookingEventType,
tenantId: string,
payload: TPayload
): void {
const event: BookingEvent<TPayload> = {
eventType,
timestamp: new Date().toISOString(),
tenantId,
payload,
};
const subs = this.subscriptions.get(eventType) ?? [];
for (const sub of subs) {
sub.handler(event as BookingEvent);
}
}
public clear(): void {
this.subscriptions.clear();
}
public listenerCount(eventType: BookingEventType): number {
return this.subscriptions.get(eventType)?.length ?? 0;
}
public eventTypes(): BookingEventType[] {
return [...this.subscriptions.keys()];
}
}
export function createEventBus(): EventBus {
return new EventBus();
}

View File

@@ -0,0 +1 @@
export { createEventBus, EventBus } from "./event-bus.js";

View File

@@ -0,0 +1,6 @@
export {
createStateMachine,
StateMachine,
type TransitionRequest,
type TransitionResult,
} from "./state-machine.js";

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,93 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { StatusTransition } from "../../types/config.types.js";
import { createStateMachine, StateMachine } from "./state-machine.js";
const transitions: StatusTransition[] = [
{
from: "pending",
to: "confirmed",
allowedRoles: ["system"],
requiresReason: false,
autoTransitionAfter: null,
},
{
from: "confirmed",
to: "cancelled",
allowedRoles: ["customer"],
requiresReason: true,
autoTransitionAfter: null,
},
{
from: "confirmed",
to: "no_show",
allowedRoles: ["system"],
requiresReason: false,
autoTransitionAfter: "PT24H",
},
];
describe("StateMachine", () => {
it("undefined transitions are disallowed", { timeout: 5000 }, () => {
const sm = createStateMachine(transitions);
const result = sm.canTransition({
from: "pending",
to: "completed",
role: "system",
});
expect(result.allowed).toBe(false);
expect(result.message).toContain("not defined");
});
it("role-restricted transitions are disallowed for wrong role", {
timeout: 5000,
}, () => {
const sm = new StateMachine(transitions);
const result = sm.canTransition({
from: "pending",
to: "confirmed",
role: "customer",
});
expect(result.allowed).toBe(false);
expect(result.message).toContain("not allowed");
});
it("requiresReason without reason is disallowed", { timeout: 5000 }, () => {
const sm = new StateMachine(transitions);
const result = sm.canTransition({
from: "confirmed",
to: "cancelled",
role: "customer",
});
expect(result.allowed).toBe(false);
expect(result.message).toContain("Reason");
});
it("transition allowed with reason", { timeout: 5000 }, () => {
const sm = new StateMachine(transitions);
const result = sm.canTransition({
from: "confirmed",
to: "cancelled",
role: "customer",
reason: "x",
});
expect(result.allowed).toBe(true);
});
it("getAvailableTransitions / getAutoTransitions / getAllStatuses filter correctly", {
timeout: 5000,
}, () => {
const sm = new StateMachine(transitions);
expect(
sm.getAvailableTransitions("confirmed", "customer").map((t) => t.to)
).toEqual(["cancelled"]);
expect(sm.getAutoTransitions("confirmed").map((t) => t.to)).toEqual([
"no_show",
]);
expect(sm.getAllStatuses().sort()).toEqual([
"cancelled",
"confirmed",
"no_show",
"pending",
]);
});
});

View File

@@ -0,0 +1,98 @@
/**
* Configurable state machine for booking status transitions. Transitions are
* defined via JSON configuration, not hard-coded.
*/
import type { StatusTransition } from "../../types/config.types.js";
export interface TransitionRequest {
from: string;
to: string;
role: string;
reason?: string;
}
export interface TransitionResult {
allowed: boolean;
from: string;
to: string;
message: string;
}
export class StateMachine {
private readonly transitions: StatusTransition[];
public constructor(transitions: StatusTransition[]) {
this.transitions = transitions;
}
public canTransition(request: TransitionRequest): TransitionResult {
const match = this.transitions.find(
(t) => t.from === request.from && t.to === request.to
);
if (!match) {
return {
allowed: false,
from: request.from,
to: request.to,
message: `Transition from '${request.from}' to '${request.to}' is not defined`,
};
}
if (!match.allowedRoles.includes(request.role)) {
return {
allowed: false,
from: request.from,
to: request.to,
message: `Role '${request.role}' is not allowed to transition from '${request.from}' to '${request.to}'`,
};
}
if (match.requiresReason && !request.reason) {
return {
allowed: false,
from: request.from,
to: request.to,
message: `Reason is required for transition from '${request.from}' to '${request.to}'`,
};
}
return {
allowed: true,
from: request.from,
to: request.to,
message: "Transition allowed",
};
}
public getAvailableTransitions(
fromStatus: string,
role: string
): StatusTransition[] {
return this.transitions.filter(
(t) => t.from === fromStatus && t.allowedRoles.includes(role)
);
}
public getAutoTransitions(fromStatus: string): StatusTransition[] {
return this.transitions.filter(
(t) => t.from === fromStatus && t.autoTransitionAfter !== null
);
}
public getAllStatuses(): string[] {
const statuses = new Set<string>();
for (const t of this.transitions) {
statuses.add(t.from);
statuses.add(t.to);
}
return [...statuses];
}
}
export function createStateMachine(
transitions: StatusTransition[]
): StateMachine {
return new StateMachine(transitions);
}

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,15 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createAccommodationConfig } from "./accommodation.js";
describe("createAccommodationConfig", () => {
it("createAccommodationConfig sets range_overlap with PT4H buffer", {
timeout: 5000,
}, () => {
const cfg = createAccommodationConfig("hotel", "Hotel");
expect(cfg.engineRef.domainPreset).toBe("accommodation");
expect(cfg.conflictRef.mode).toBe("range_overlap");
expect(cfg.conflictRef.allowSameBoundaryTurnover).toBe(true);
expect(cfg.conflictRef.bufferDuration).toBe("PT4H");
expect(cfg.engineRef.maxAdvanceBookingDays).toBe(365);
});
});

View File

@@ -0,0 +1,91 @@
/**
* Accommodation booking domain preset. Factory for hotel, vacation rental,
* and hostel configurations.
*/
import type { BookingSystemConfig } from "../../types/config.types.js";
import { createBaseConfig } from "../shared.js";
export function createAccommodationConfig(
tenantId: string,
tenantName: string,
overrides: Partial<BookingSystemConfig> = {}
): BookingSystemConfig {
const base = createBaseConfig(tenantId, tenantName);
return {
...base,
engineRef: {
...base.engineRef,
domainPreset: "accommodation",
maxAdvanceBookingDays: 365,
},
conflictRef: {
mode: "range_overlap",
allowSameBoundaryTurnover: true,
bufferDuration: "PT4H",
holdDuration: "PT15M",
capacityMode: "absolute",
},
bookableTypeRegistry: [
{
typeId: "hotel-room",
label: "Hotel Room",
description: "Bookable hotel room",
requiredAttributes: [
{
attributeId: "roomNumber",
label: "Room Number",
dataType: "string",
enumValues: [],
required: true,
defaultValue: "",
},
{
attributeId: "roomTypeId",
label: "Room Type",
dataType: "enum",
enumValues: [
"standard",
"deluxe",
"suite",
"penthouse",
],
required: true,
defaultValue: "standard",
},
{
attributeId: "maxOccupancy",
label: "Max Occupancy",
dataType: "number",
enumValues: [],
required: true,
defaultValue: 2,
},
],
optionalAttributes: [
{
attributeId: "floor",
label: "Floor",
dataType: "number",
enumValues: [],
required: false,
defaultValue: 1,
},
{
attributeId: "viewType",
label: "View",
dataType: "enum",
enumValues: ["sea", "garden", "city", "pool"],
required: false,
defaultValue: "city",
},
],
defaultPricingModel: "per_duration",
defaultConflictMode: "range_overlap",
supportsLayout: true,
},
],
...overrides,
};
}

View File

@@ -0,0 +1,14 @@
/**
* Accommodation booking domain types. Used for hotels, vacation rentals,
* hostels.
*/
export interface AccommodationBookableMeta {
roomNumber: string;
roomTypeId: string;
floor: number;
bedConfiguration: string;
maxOccupancy: number;
amenityIds: string[];
viewType: string;
}

View File

@@ -0,0 +1,2 @@
export { createAccommodationConfig } from "./accommodation.js";
export type { AccommodationBookableMeta } from "./accommodation.types.js";

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,13 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createActivityConfig } from "./activities.js";
describe("createActivityConfig", () => {
it("createActivityConfig sets slot_capacity and enableWaitlist", {
timeout: 5000,
}, () => {
const cfg = createActivityConfig("studio", "Studio");
expect(cfg.engineRef.domainPreset).toBe("activities");
expect(cfg.conflictRef.mode).toBe("slot_capacity");
expect(cfg.engineRef.enableWaitlist).toBe(true);
});
});

View File

@@ -0,0 +1,86 @@
/**
* Activity/tour booking domain preset. Factory for tours, classes, and group
* event configurations.
*/
import type { BookingSystemConfig } from "../../types/config.types.js";
import { createBaseConfig } from "../shared.js";
export function createActivityConfig(
tenantId: string,
tenantName: string,
overrides: Partial<BookingSystemConfig> = {}
): BookingSystemConfig {
const base = createBaseConfig(tenantId, tenantName);
return {
...base,
engineRef: {
...base.engineRef,
domainPreset: "activities",
enableWaitlist: true,
},
conflictRef: {
mode: "slot_capacity",
allowSameBoundaryTurnover: false,
bufferDuration: "PT0S",
holdDuration: "PT10M",
capacityMode: "absolute",
},
bookableTypeRegistry: [
{
typeId: "activity",
label: "Activity",
description: "Group activity or tour",
requiredAttributes: [
{
attributeId: "activityTypeId",
label: "Type",
dataType: "string",
enumValues: [],
required: true,
defaultValue: "",
},
{
attributeId: "maxParticipants",
label: "Max Participants",
dataType: "number",
enumValues: [],
required: true,
defaultValue: 20,
},
],
optionalAttributes: [
{
attributeId: "minParticipants",
label: "Min Participants",
dataType: "number",
enumValues: [],
required: false,
defaultValue: 1,
},
{
attributeId: "instructorRef",
label: "Instructor",
dataType: "string",
enumValues: [],
required: false,
defaultValue: "",
},
{
attributeId: "difficultyLevel",
label: "Difficulty",
dataType: "enum",
enumValues: ["beginner", "intermediate", "advanced"],
required: false,
defaultValue: "beginner",
},
],
defaultPricingModel: "per_unit",
defaultConflictMode: "slot_capacity",
supportsLayout: false,
},
],
...overrides,
};
}

View File

@@ -0,0 +1,13 @@
/**
* Activity/tour booking domain types. Used for tours, classes, group events.
*/
export interface ActivityBookableMeta {
activityTypeId: string;
instructorRef: string;
minParticipants: number;
maxParticipants: number;
difficultyLevel: string;
equipmentIncluded: boolean;
meetingPointRef: string;
}

View File

@@ -0,0 +1,2 @@
export { createActivityConfig } from "./activities.js";
export type { ActivityBookableMeta } from "./activities.types.js";

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,15 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createAppointmentConfig } from "./appointments.js";
describe("createAppointmentConfig", () => {
it("createAppointmentConfig sets provider_overlap with PT15M buffer", {
timeout: 5000,
}, () => {
const cfg = createAppointmentConfig("clinic", "Clinic");
expect(cfg.engineRef.domainPreset).toBe("appointments");
expect(cfg.conflictRef.mode).toBe("provider_overlap");
expect(cfg.conflictRef.bufferDuration).toBe("PT15M");
expect(cfg.engineRef.enableRecurring).toBe(true);
expect(cfg.engineRef.enableWaitlist).toBe(true);
});
});

View File

@@ -0,0 +1,95 @@
/**
* Appointment booking domain preset. Factory for medical, dental, salon, and
* consulting configurations.
*/
import type { BookingSystemConfig } from "../../types/config.types.js";
import { createBaseConfig } from "../shared.js";
export function createAppointmentConfig(
tenantId: string,
tenantName: string,
overrides: Partial<BookingSystemConfig> = {}
): BookingSystemConfig {
const base = createBaseConfig(tenantId, tenantName);
return {
...base,
engineRef: {
...base.engineRef,
domainPreset: "appointments",
enableRecurring: true,
enableWaitlist: true,
},
conflictRef: {
mode: "provider_overlap",
allowSameBoundaryTurnover: false,
bufferDuration: "PT15M",
holdDuration: "PT5M",
capacityMode: "absolute",
},
bookableTypeRegistry: [
{
typeId: "appointment",
label: "Appointment",
description: "Time-based appointment with a provider",
requiredAttributes: [
{
attributeId: "providerId",
label: "Provider",
dataType: "string",
enumValues: [],
required: true,
defaultValue: "",
},
{
attributeId: "serviceTypeId",
label: "Service Type",
dataType: "string",
enumValues: [],
required: true,
defaultValue: "",
},
{
attributeId: "durationMinutes",
label: "Duration (min)",
dataType: "number",
enumValues: [],
required: true,
defaultValue: 30,
},
],
optionalAttributes: [
{
attributeId: "locationId",
label: "Location",
dataType: "string",
enumValues: [],
required: false,
defaultValue: "",
},
{
attributeId: "bufferBeforeMinutes",
label: "Buffer Before",
dataType: "number",
enumValues: [],
required: false,
defaultValue: 0,
},
{
attributeId: "bufferAfterMinutes",
label: "Buffer After",
dataType: "number",
enumValues: [],
required: false,
defaultValue: 0,
},
],
defaultPricingModel: "fixed",
defaultConflictMode: "provider_overlap",
supportsLayout: false,
},
],
...overrides,
};
}

View File

@@ -0,0 +1,15 @@
/**
* Appointment booking domain types. Used for medical, dental, salon,
* consulting.
*/
export interface AppointmentBookableMeta {
providerId: string;
serviceTypeId: string;
durationMinutes: number;
locationId: string;
bufferBeforeMinutes: number;
bufferAfterMinutes: number;
allowConcurrent: boolean;
maxConcurrent: number;
}

View File

@@ -0,0 +1,2 @@
export { createAppointmentConfig } from "./appointments.js";
export type { AppointmentBookableMeta } from "./appointments.types.js";

View File

@@ -0,0 +1,2 @@
export { createResourceConfig } from "./resources.js";
export type { ResourceBookableMeta } from "./resources.types.js";

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,11 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createResourceConfig } from "./resources.js";
describe("createResourceConfig", () => {
it("createResourceConfig sets slot_capacity", { timeout: 5000 }, () => {
const cfg = createResourceConfig("co", "Co");
expect(cfg.engineRef.domainPreset).toBe("resources");
expect(cfg.conflictRef.mode).toBe("slot_capacity");
expect(cfg.bookableTypeRegistry[0]?.typeId).toBe("resource");
});
});

View File

@@ -0,0 +1,77 @@
/**
* Resource booking domain preset. Factory for meeting room, equipment, and
* vehicle configurations.
*/
import type { BookingSystemConfig } from "../../types/config.types.js";
import { createBaseConfig } from "../shared.js";
export function createResourceConfig(
tenantId: string,
tenantName: string,
overrides: Partial<BookingSystemConfig> = {}
): BookingSystemConfig {
const base = createBaseConfig(tenantId, tenantName);
return {
...base,
engineRef: {
...base.engineRef,
domainPreset: "resources",
},
conflictRef: {
mode: "slot_capacity",
allowSameBoundaryTurnover: false,
bufferDuration: "PT0S",
holdDuration: "PT10M",
capacityMode: "absolute",
},
bookableTypeRegistry: [
{
typeId: "resource",
label: "Resource",
description: "Bookable resource with capacity",
requiredAttributes: [
{
attributeId: "resourceTypeId",
label: "Type",
dataType: "string",
enumValues: [],
required: true,
defaultValue: "",
},
{
attributeId: "capacity",
label: "Capacity",
dataType: "number",
enumValues: [],
required: true,
defaultValue: 1,
},
],
optionalAttributes: [
{
attributeId: "locationId",
label: "Location",
dataType: "string",
enumValues: [],
required: false,
defaultValue: "",
},
{
attributeId: "requiresApproval",
label: "Requires Approval",
dataType: "boolean",
enumValues: [],
required: false,
defaultValue: false,
},
],
defaultPricingModel: "per_duration",
defaultConflictMode: "slot_capacity",
supportsLayout: true,
},
],
...overrides,
};
}

View File

@@ -0,0 +1,12 @@
/**
* Resource booking domain types. Used for meeting rooms, equipment, vehicles.
*/
export interface ResourceBookableMeta {
resourceTypeId: string;
locationId: string;
capacity: number;
featureIds: string[];
requiresApproval: boolean;
approverRef: string;
}

View File

@@ -0,0 +1,2 @@
export { createSeatConfig } from "./seats.js";
export type { SeatBookableMeta } from "./seats.types.js";

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,14 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createSeatConfig } from "./seats.js";
describe("createSeatConfig", () => {
it("createSeatConfig sets domainPreset and exact_match mode", {
timeout: 5000,
}, () => {
const cfg = createSeatConfig("arena", "Arena");
expect(cfg.engineRef.domainPreset).toBe("seats");
expect(cfg.conflictRef.mode).toBe("exact_match");
expect(cfg.bookableTypeRegistry[0]?.typeId).toBe("seat");
expect(cfg.bookableTypeRegistry[0]?.supportsLayout).toBe(true);
});
});

View File

@@ -0,0 +1,93 @@
/**
* Seat-based booking domain preset. Factory for stadium, theatre, cinema, and
* flight configurations.
*/
import type { BookingSystemConfig } from "../../types/config.types.js";
import { createBaseConfig } from "../shared.js";
export function createSeatConfig(
tenantId: string,
tenantName: string,
overrides: Partial<BookingSystemConfig> = {}
): BookingSystemConfig {
const base = createBaseConfig(tenantId, tenantName);
return {
...base,
engineRef: {
...base.engineRef,
domainPreset: "seats",
},
conflictRef: {
mode: "exact_match",
allowSameBoundaryTurnover: false,
bufferDuration: "PT0S",
holdDuration: "PT10M",
capacityMode: "absolute",
},
bookableTypeRegistry: [
{
typeId: "seat",
label: "Seat",
description: "Individual seat in a venue",
requiredAttributes: [
{
attributeId: "seatNumber",
label: "Seat Number",
dataType: "string",
enumValues: [],
required: true,
defaultValue: "",
},
{
attributeId: "rowLabel",
label: "Row",
dataType: "string",
enumValues: [],
required: true,
defaultValue: "",
},
{
attributeId: "sectionLabel",
label: "Section",
dataType: "string",
enumValues: [],
required: true,
defaultValue: "",
},
],
optionalAttributes: [
{
attributeId: "tier",
label: "Tier",
dataType: "enum",
enumValues: ["vip", "premium", "standard"],
required: false,
defaultValue: "standard",
},
{
attributeId: "isAccessible",
label: "Accessible",
dataType: "boolean",
enumValues: [],
required: false,
defaultValue: false,
},
{
attributeId: "hasObstructedView",
label: "Obstructed View",
dataType: "boolean",
enumValues: [],
required: false,
defaultValue: false,
},
],
defaultPricingModel: "fixed",
defaultConflictMode: "exact_match",
supportsLayout: true,
},
],
...overrides,
};
}

View File

@@ -0,0 +1,14 @@
/**
* Seat-based booking domain types. Used for stadiums, theatres, cinemas,
* flights.
*/
export interface SeatBookableMeta {
seatNumber: string;
rowLabel: string;
sectionLabel: string;
tier: string;
isAccessible: boolean;
hasObstructedView: boolean;
adjacentSeatIds: string[];
}

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,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createBaseConfig } from "./shared.js";
describe("createBaseConfig", () => {
it("createBaseConfig populates every field with documented defaults", {
timeout: 5000,
}, () => {
const cfg = createBaseConfig("t1", "Tenant 1");
expect(cfg.schemaVersion).toBe("1.0.0");
expect(cfg.tenantRef.tenantId).toBe("t1");
expect(cfg.tenantRef.tenantName).toBe("Tenant 1");
expect(cfg.engineRef.domainPreset).toBe("custom");
expect(cfg.engineRef.enableHolds).toBe(true);
expect(cfg.availabilityRef.cacheEnabled).toBe(true);
expect(cfg.pricingRef.defaultCurrency).toBe("USD");
expect(cfg.holdRef.defaultDurationMinutes).toBe(10);
expect(cfg.waitlistRef.enabled).toBe(false);
expect(cfg.notificationRef.enabled).toBe(false);
expect(cfg.rules).toEqual([]);
expect(cfg.statusTransitions.length).toBeGreaterThan(0);
expect(cfg.bookableTypeRegistry).toEqual([]);
expect(cfg.layoutRef).toBeNull();
expect(cfg.localeRef.defaultLocale).toBe("en-US");
});
it("overrides win over preset defaults", { timeout: 5000 }, async () => {
const { createSeatConfig } = await import("./seats/seats.js");
const cfg = createSeatConfig("t1", "T1", {
engineRef: {
domainPreset: "seats",
customStrategies: [],
enableHolds: true,
enableWaitlist: true,
enableRecurring: false,
maxBookingsPerCustomer: 5,
maxAdvanceBookingDays: 180,
},
});
expect(cfg.engineRef.maxBookingsPerCustomer).toBe(5);
});
});

View File

@@ -0,0 +1,121 @@
/**
* Shared base configuration factory used by all domain presets. Provides
* sensible defaults that domains can override.
*/
import type { BookingSystemConfig } from "../types/config.types.js";
export function createBaseConfig(
tenantId: string,
tenantName: string
): BookingSystemConfig {
return {
schemaVersion: "1.0.0",
tenantRef: {
tenantId,
tenantName,
multiTenant: false,
isolationLevel: "row",
},
engineRef: {
domainPreset: "custom",
customStrategies: [],
enableHolds: true,
enableWaitlist: false,
enableRecurring: false,
maxBookingsPerCustomer: 0,
maxAdvanceBookingDays: 90,
},
availabilityRef: {
refreshIntervalMs: 30000,
cacheEnabled: true,
cacheTtlMs: 60000,
},
conflictRef: {
mode: "range_overlap",
allowSameBoundaryTurnover: false,
bufferDuration: "PT0S",
holdDuration: "PT10M",
capacityMode: "absolute",
},
pricingRef: {
defaultCurrency: "USD",
supportedCurrencies: ["USD"],
taxStrategy: "exclusive",
roundingMode: "round",
roundingPrecision: 2,
},
holdRef: {
enabled: true,
defaultDurationMinutes: 10,
maxDurationMinutes: 30,
autoReleaseEnabled: true,
},
waitlistRef: {
enabled: false,
maxEntriesPerCustomer: 3,
autoNotify: true,
notificationWindowMinutes: 30,
autoExpireAfterMinutes: 1440,
},
notificationRef: {
enabled: false,
channels: [],
events: [],
},
rules: [],
conditions: [],
priceRules: [],
priceTiers: [],
priceModifiers: [],
seasonalRates: [],
dynamicPricingRules: [],
statusTransitions: [
{
from: "pending",
to: "confirmed",
allowedRoles: ["system", "admin"],
requiresReason: false,
autoTransitionAfter: null,
},
{
from: "confirmed",
to: "checked_in",
allowedRoles: ["staff", "admin"],
requiresReason: false,
autoTransitionAfter: null,
},
{
from: "checked_in",
to: "completed",
allowedRoles: ["staff", "admin"],
requiresReason: false,
autoTransitionAfter: null,
},
{
from: "confirmed",
to: "cancelled",
allowedRoles: ["customer", "admin"],
requiresReason: true,
autoTransitionAfter: null,
},
{
from: "confirmed",
to: "no_show",
allowedRoles: ["system", "admin"],
requiresReason: false,
autoTransitionAfter: "PT24H",
},
],
bookableTypeRegistry: [],
layoutRef: null,
localeRef: {
defaultLocale: "en-US",
supportedLocales: ["en-US"],
dateFormat: "MM/dd/yyyy",
timeFormat: "12h",
firstDayOfWeek: 0,
currencyDisplay: "symbol",
},
};
}

View File

@@ -0,0 +1,2 @@
export { createTransportConfig } from "./transport.js";
export type { TransportBookableMeta } from "./transport.types.js";

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,14 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createTransportConfig } from "./transport.js";
describe("createTransportConfig", () => {
it("createTransportConfig sets exact_match and enableWaitlist", {
timeout: 5000,
}, () => {
const cfg = createTransportConfig("air", "Air");
expect(cfg.engineRef.domainPreset).toBe("transport");
expect(cfg.conflictRef.mode).toBe("exact_match");
expect(cfg.engineRef.enableWaitlist).toBe(true);
expect(cfg.conflictRef.holdDuration).toBe("PT15M");
});
});

View File

@@ -0,0 +1,86 @@
/**
* Transport booking domain preset. Factory for flight, train, and bus
* configurations.
*/
import type { BookingSystemConfig } from "../../types/config.types.js";
import { createBaseConfig } from "../shared.js";
export function createTransportConfig(
tenantId: string,
tenantName: string,
overrides: Partial<BookingSystemConfig> = {}
): BookingSystemConfig {
const base = createBaseConfig(tenantId, tenantName);
return {
...base,
engineRef: {
...base.engineRef,
domainPreset: "transport",
enableWaitlist: true,
},
conflictRef: {
mode: "exact_match",
allowSameBoundaryTurnover: false,
bufferDuration: "PT0S",
holdDuration: "PT15M",
capacityMode: "absolute",
},
bookableTypeRegistry: [
{
typeId: "transport-seat",
label: "Transport Seat",
description: "Seat on a transport vehicle",
requiredAttributes: [
{
attributeId: "vehicleId",
label: "Vehicle",
dataType: "string",
enumValues: [],
required: true,
defaultValue: "",
},
{
attributeId: "routeId",
label: "Route",
dataType: "string",
enumValues: [],
required: true,
defaultValue: "",
},
{
attributeId: "classId",
label: "Class",
dataType: "enum",
enumValues: ["economy", "business", "first"],
required: true,
defaultValue: "economy",
},
],
optionalAttributes: [
{
attributeId: "seatNumber",
label: "Seat Number",
dataType: "string",
enumValues: [],
required: false,
defaultValue: "",
},
{
attributeId: "legIndex",
label: "Leg",
dataType: "number",
enumValues: [],
required: false,
defaultValue: 0,
},
],
defaultPricingModel: "fixed",
defaultConflictMode: "exact_match",
supportsLayout: true,
},
],
...overrides,
};
}

View File

@@ -0,0 +1,13 @@
/**
* Transport booking domain types. Used for flights, trains, buses.
*/
export interface TransportBookableMeta {
vehicleId: string;
routeId: string;
classId: string;
seatNumber: string | null;
departureStationId: string;
arrivalStationId: string;
legIndex: number;
}

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,183 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BOOKING_CODES, BookingError } from "./error-codes.js";
describe("BOOKING_CODES + BookingError", () => {
it("BOOKING_CODES contains every documented code", {
timeout: 5000,
}, () => {
expect(BOOKING_CODES.ENGINE_NOT_CONFIGURED).toBe(
"BOOKING.ENGINE_NOT_CONFIGURED"
);
expect(BOOKING_CODES.BOOKING_NOT_FOUND).toBe(
"BOOKING.BOOKING_NOT_FOUND"
);
expect(BOOKING_CODES.BOOKING_CONFLICT).toBe("BOOKING.BOOKING_CONFLICT");
expect(BOOKING_CODES.TRANSITION_NOT_ALLOWED).toBe(
"BOOKING.TRANSITION_NOT_ALLOWED"
);
expect(BOOKING_CODES.UNKNOWN_CONFLICT_MODE).toBe(
"BOOKING.UNKNOWN_CONFLICT_MODE"
);
expect(BOOKING_CODES.UNKNOWN_AVAILABILITY_STRATEGY).toBe(
"BOOKING.UNKNOWN_AVAILABILITY_STRATEGY"
);
expect(BOOKING_CODES.HOOK_RETURNED_PROMISE_IN_SYNC).toBe(
"BOOKING.HOOK_RETURNED_PROMISE_IN_SYNC"
);
expect(Object.keys(BOOKING_CODES)).toHaveLength(7);
});
it("BookingError carries optional discriminants", { timeout: 5000 }, () => {
const err = new BookingError(
BOOKING_CODES.BOOKING_NOT_FOUND,
"missing",
{ bookingId: "b1", holdId: "h1", mode: "m1", strategy: "s1" }
);
expect(err.bookingId).toBe("b1");
expect(err.holdId).toBe("h1");
expect(err.mode).toBe("m1");
expect(err.strategy).toBe("s1");
expect(err).toBeInstanceOf(SuaveplanError);
expect(err.code).toBe(BOOKING_CODES.BOOKING_NOT_FOUND);
});
it("BookingError omits unset discriminants from the instance", {
timeout: 5000,
}, () => {
const err = new BookingError(
BOOKING_CODES.ENGINE_NOT_CONFIGURED,
"not configured"
);
expect(err.bookingId).toBeUndefined();
expect(err.holdId).toBeUndefined();
expect(err.mode).toBeUndefined();
expect(err.strategy).toBeUndefined();
});
it("every code is reachable from a production path", {
timeout: 5000,
}, async () => {
// ENGINE_NOT_CONFIGURED — engine.checkAvailability before configure
const { createBookingEngine } = await import(
"../core/engine/engine.js"
);
const engine = createBookingEngine();
expect(() =>
engine.checkAvailability({
bookableRef: "x",
windowRef: {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
},
quantity: 1,
})
).toThrowError(BookingError);
// BOOKING_NOT_FOUND — engine.cancelBooking missing id
const { createSeatConfig } = await import("../domains/seats/seats.js");
engine.configure(createSeatConfig("t1", "T1"));
expect(() => engine.cancelBooking("nope", "x")).toThrowError(
BookingError
);
// BOOKING_CONFLICT — confirmBooking with overlapping booking
engine.registerBookable({
identifierRef: { id: "b", tenantId: "t1", categoryId: "c" },
name: "B",
description: "",
bookableType: "seat",
status: "active",
layoutNodeRef: null,
capacityRef: "",
priceRuleRefs: [],
timeSlotRefs: [],
attributes: {},
});
const window = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
};
engine.confirmBooking({
bookableRef: "b",
customerRef: "c1",
windowRef: window,
quantity: 1,
});
expect(() =>
engine.confirmBooking({
bookableRef: "b",
customerRef: "c2",
windowRef: window,
quantity: 1,
})
).toThrowError(BookingError);
// TRANSITION_NOT_ALLOWED — cancel a booking whose status has no cancel transition
const booking = engine.confirmBooking({
bookableRef: "b",
customerRef: "c3",
windowRef: {
startTime: "2026-06-02T10:00:00Z",
endTime: "2026-06-02T11:00:00Z",
timezone: "UTC",
},
quantity: 1,
});
// First cancellation is allowed (confirmed→cancelled), second from "cancelled" is not.
engine.cancelBooking(booking.identifierRef.id, "reason");
expect(() =>
engine.cancelBooking(booking.identifierRef.id, "again")
).toThrowError(BookingError);
// UNKNOWN_CONFLICT_MODE — detectConflicts with unregistered mode
const { detectConflicts, clearDetectorRegistry } = await import(
"../conflict/detector/detector.js"
);
clearDetectorRegistry();
expect(() =>
detectConflicts(window, [], {
mode: "made-up",
allowSameBoundaryTurnover: false,
bufferDuration: "PT0S",
holdDuration: "PT0M",
capacityMode: "absolute",
})
).toThrowError(BookingError);
// UNKNOWN_AVAILABILITY_STRATEGY — resolveAvailability with unregistered strategy
const { resolveAvailability, clearStrategyRegistry } = await import(
"../availability/resolver/resolver.js"
);
clearStrategyRegistry();
expect(() =>
resolveAvailability(
"made-up",
{
identifierRef: { id: "x", tenantId: "t", categoryId: "c" },
name: "",
description: "",
bookableType: "seat",
status: "",
layoutNodeRef: null,
capacityRef: "",
priceRuleRefs: [],
timeSlotRefs: [],
attributes: {},
},
{ bookableRef: "x", windowRef: window, quantity: 1 },
[]
)
).toThrowError(BookingError);
// HOOK_RETURNED_PROMISE_IN_SYNC — Hooks.applyHooks with async handler
const { Hooks } = await import("../hooks/hooks.js");
const hooks = new Hooks<{ ping: { v: number } }>();
hooks.hook("ping", async (data) => ({ v: data.v + 1 }));
expect(() => hooks.applyHooks("ping", { v: 0 })).toThrowError(
BookingError
);
});
});

View File

@@ -0,0 +1,64 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/booking`. Every public failure
* path raises a `BookingError` carrying one of these codes; consumers
* programmatically dispatch on the namespaced string (`"BOOKING.BOOKING_CONFLICT"`).
*/
export const BOOKING_CODES = defineCodes("BOOKING", [
"ENGINE_NOT_CONFIGURED",
"BOOKING_NOT_FOUND",
"BOOKING_CONFLICT",
"TRANSITION_NOT_ALLOWED",
"UNKNOWN_CONFLICT_MODE",
"UNKNOWN_AVAILABILITY_STRATEGY",
"HOOK_RETURNED_PROMISE_IN_SYNC",
] as const);
export type BookingErrorCode =
(typeof BOOKING_CODES)[keyof typeof BOOKING_CODES];
export interface BookingErrorOptions extends SuaveplanErrorOptions {
/** Booking identifier that triggered the error, when applicable. */
readonly bookingId?: string;
/** Hold identifier that triggered the error, when applicable. */
readonly holdId?: string;
/** Conflict-detection mode that triggered the error, when applicable. */
readonly mode?: string;
/** Availability strategy identifier, when applicable. */
readonly strategy?: string;
}
/**
* Discriminated error class for `@suaveplan/booking`. Extends `SuaveplanError`
* so `instanceof` narrows `code` to `BookingErrorCode`. Carries optional
* `bookingId` / `holdId` / `mode` / `strategy` discriminants for diagnostics
* without polluting the base `context` map.
*/
export class BookingError extends SuaveplanError<BookingErrorCode> {
public readonly bookingId?: string;
public readonly holdId?: string;
public readonly mode?: string;
public readonly strategy?: string;
public constructor(
code: BookingErrorCode,
message: string,
options: BookingErrorOptions = {}
) {
super(code, message, options);
if (options.bookingId !== undefined) {
this.bookingId = options.bookingId;
}
if (options.holdId !== undefined) {
this.holdId = options.holdId;
}
if (options.mode !== undefined) {
this.mode = options.mode;
}
if (options.strategy !== undefined) {
this.strategy = options.strategy;
}
}
}

View File

@@ -0,0 +1,8 @@
export type {
BookingErrorCode,
BookingErrorOptions,
} from "./error-codes.js";
export {
BOOKING_CODES,
BookingError,
} from "./error-codes.js";

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,238 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BOOKING_CODES, BookingError } from "../error-codes/error-codes.js";
import { Hooks } from "./hooks.js";
interface Map {
"u:c": { name: string; n: number };
other: { x: number };
}
describe("Hooks", () => {
it("runs handlers in priority order", { timeout: 5000 }, () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", (d) => ({ ...d, name: `${d.name}-low` }), {
priority: 0,
});
hooks.hook("u:c", (d) => ({ ...d, name: `${d.name}-high` }), {
priority: 10,
});
const out = hooks.applyHooks("u:c", { name: "x", n: 0 });
expect(out.name).toBe("x-high-low");
});
it("returning undefined passes data through", { timeout: 5000 }, () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", () => undefined);
hooks.hook("u:c", (d) => ({ ...d, n: d.n + 5 }));
const out = hooks.applyHooks("u:c", { name: "a", n: 1 });
expect(out.n).toBe(6);
});
it("context.abort stops the chain", { timeout: 5000 }, async () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", (d, ctx) => {
ctx.abort();
return { ...d, n: 10 };
});
hooks.hook("u:c", (d) => ({ ...d, n: d.n + 100 }));
const result = await hooks.applyHooksWithResult("u:c", {
name: "",
n: 0,
});
expect(result.aborted).toBe(true);
expect(result.data.n).toBe(10);
expect(result.handlersRun).toBe(1);
});
it("once handlers auto-remove", { timeout: 5000 }, () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", (d) => ({ ...d, n: d.n + 1 }), { once: true });
hooks.applyHooks("u:c", { name: "", n: 0 });
expect(hooks.hookCount("u:c")).toBe(0);
const out = hooks.applyHooks("u:c", { name: "", n: 0 });
expect(out.n).toBe(0);
});
it("sync applyHooks rejects async return", { timeout: 5000 }, () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", async (d) => ({ ...d, n: d.n + 1 }));
expect(() => hooks.applyHooks("u:c", { name: "", n: 0 })).toThrow(
BookingError
);
try {
hooks.applyHooks("u:c", { name: "", n: 0 });
} catch (err) {
expect(err).toBeInstanceOf(BookingError);
if (err instanceof BookingError) {
expect(err.code).toBe(
BOOKING_CODES.HOOK_RETURNED_PROMISE_IN_SYNC
);
}
}
});
it("applyHooksAsync awaits async handlers", { timeout: 5000 }, async () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", async (d) => ({ ...d, n: d.n + 5 }));
hooks.hook("u:c", (d) => ({ ...d, n: d.n * 2 }), { priority: -1 });
const out = await hooks.applyHooksAsync("u:c", { name: "", n: 1 });
expect(out.n).toBe(12);
});
it("hookCount and hasHook reflect registry state", {
timeout: 5000,
}, () => {
const hooks = new Hooks<Map>();
expect(hooks.hookCount("u:c")).toBe(0);
expect(hooks.hasHook("u:c")).toBe(false);
const unsub = hooks.hook("u:c", (d) => d, { label: "id" });
expect(hooks.hookCount("u:c")).toBe(1);
expect(hooks.hasHook("u:c")).toBe(true);
expect(hooks.hookNames()).toContain("u:c");
unsub();
expect(hooks.hookCount("u:c")).toBe(0);
expect(hooks.hookNames()).not.toContain("u:c");
});
it("unhook ignores unknown hooks and removes by reference", {
timeout: 5000,
}, () => {
const hooks = new Hooks<Map>();
const h = (d: Map["u:c"]) => d;
hooks.unhook("u:c", h); // no-op when nothing registered
hooks.hook("u:c", h);
hooks.unhook("u:c", h);
expect(hooks.hookCount("u:c")).toBe(0);
});
it("unhookAll(name) clears a single hook; unhookAll() clears all", {
timeout: 5000,
}, () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", (d) => d);
hooks.hook("other", (d) => d);
hooks.unhookAll("u:c");
expect(hooks.hookCount("u:c")).toBe(0);
expect(hooks.hookCount("other")).toBe(1);
hooks.unhookAll();
expect(hooks.hookCount("other")).toBe(0);
});
it("applyHooks/applyHooksAsync no-op when nothing registered", {
timeout: 5000,
}, async () => {
const hooks = new Hooks<Map>();
expect(hooks.applyHooks("u:c", { name: "x", n: 1 })).toEqual({
name: "x",
n: 1,
});
expect(await hooks.applyHooksAsync("u:c", { name: "x", n: 2 })).toEqual(
{ name: "x", n: 2 }
);
const result = await hooks.applyHooksWithResult("u:c", {
name: "x",
n: 3,
});
expect(result.handlersRun).toBe(0);
expect(result.aborted).toBe(false);
});
it("sync applyHooks honours context.abort", { timeout: 5000 }, () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", (d, ctx) => {
ctx.abort();
return { ...d, n: 7 };
});
hooks.hook("u:c", (d) => ({ ...d, n: d.n + 100 }));
const out = hooks.applyHooks("u:c", { name: "", n: 0 });
expect(out.n).toBe(7);
});
it("unhook is a no-op when handler not registered for the hook", {
timeout: 5000,
}, () => {
const hooks = new Hooks<Map>();
const registered = (d: Map["u:c"]) => d;
const unregistered = (d: Map["u:c"]) => d;
hooks.hook("u:c", registered);
hooks.unhook("u:c", unregistered);
expect(hooks.hookCount("u:c")).toBe(1);
});
it("unhook of one handler leaves the rest registered", {
timeout: 5000,
}, () => {
const hooks = new Hooks<Map>();
const a = (d: Map["u:c"]) => d;
const b = (d: Map["u:c"]) => d;
hooks.hook("u:c", a);
hooks.hook("u:c", b);
hooks.unhook("u:c", a);
expect(hooks.hookCount("u:c")).toBe(1);
});
it("applyHooksAsync passes data through when handler returns undefined", {
timeout: 5000,
}, async () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", async () => undefined);
hooks.hook("u:c", async (d) => ({ ...d, n: d.n + 1 }));
const out = await hooks.applyHooksAsync("u:c", { name: "", n: 1 });
expect(out.n).toBe(2);
});
it("applyHooksWithResult passes data through when handler returns undefined", {
timeout: 5000,
}, async () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", async () => undefined);
hooks.hook("u:c", async (d) => ({ ...d, n: d.n + 1 }));
const result = await hooks.applyHooksWithResult("u:c", {
name: "",
n: 1,
});
expect(result.data.n).toBe(2);
expect(result.handlersRun).toBe(2);
});
it("applyHooksWithResult collects labels and runs counts", {
timeout: 5000,
}, async () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", (d) => ({ ...d, n: d.n + 1 }), { label: "inc" });
hooks.hook("u:c", (d) => ({ ...d, n: d.n * 2 }), {
label: "double",
once: true,
});
const result = await hooks.applyHooksWithResult("u:c", {
name: "",
n: 1,
});
expect(result.data.n).toBe(4);
expect(result.labels).toEqual(["inc", "double"]);
expect(result.handlersRun).toBe(2);
expect(hooks.hookCount("u:c")).toBe(1);
});
it("applyHooksAsync removes once handlers after execution", {
timeout: 5000,
}, async () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", (d) => ({ ...d, n: d.n + 1 }), { once: true });
await hooks.applyHooksAsync("u:c", { name: "", n: 0 });
expect(hooks.hookCount("u:c")).toBe(0);
});
it("applyHooksAsync supports abort short-circuit", {
timeout: 5000,
}, async () => {
const hooks = new Hooks<Map>();
hooks.hook("u:c", async (d, ctx) => {
ctx.abort();
return { ...d, n: 99 };
});
hooks.hook("u:c", async (d) => ({ ...d, n: d.n + 1 }));
const out = await hooks.applyHooksAsync("u:c", { name: "", n: 0 });
expect(out.n).toBe(99);
});
});

View File

@@ -0,0 +1,254 @@
/**
* In-package hookable transform pipeline.
*
* Unlike regular events (fire-and-forget), hooks form a pipeline where each
* handler can inspect and transform the data before passing it to the next
* handler. Replaces the upstream `@suaveplan/events/hooks` dependency that is
* not yet available on genesis; the public surface is preserved verbatim.
*/
import { BOOKING_CODES, BookingError } from "../error-codes/error-codes.js";
import type {
DefaultHookMap,
Hookable,
HookContext,
HookHandler,
HookHandlerOptions,
HookMap,
HookResult,
RegisteredHook,
} from "./hooks.types.js";
function createHookContext(
hook: string | symbol,
index: number,
total: number,
sharedMeta: Record<string, unknown>
): HookContext {
let aborted = false;
return {
hook,
index,
total,
abort() {
aborted = true;
},
get aborted() {
return aborted;
},
meta: sharedMeta,
};
}
function isThenable(value: unknown): value is PromiseLike<unknown> {
return (
value !== null &&
typeof value === "object" &&
"then" in (value as Record<string, unknown>) &&
typeof (value as { then?: unknown }).then === "function"
);
}
/**
* Hooks — a transform-stream-style pipeline for data.
*
* Handlers are sorted by priority (higher first) and run in sequence. Each
* handler receives the output of the previous handler. Returning `undefined`
* means "pass the data through unchanged". A handler can call `context.abort()`
* to stop the chain early.
*/
export class Hooks<TMap extends HookMap = DefaultHookMap>
implements Hookable<TMap>
{
private readonly registry = new Map<
keyof TMap | string | symbol,
RegisteredHook<unknown>[]
>();
public hook<K extends keyof TMap>(
name: K,
handler: HookHandler<TMap[K]>,
options?: HookHandlerOptions
): () => void {
const entry: RegisteredHook<TMap[K]> = {
handler,
priority: options?.priority ?? 0,
once: options?.once ?? false,
...(options?.label !== undefined ? { label: options.label } : {}),
};
const list = this.registry.get(name) ?? [];
list.push(entry as RegisteredHook<unknown>);
list.sort((a, b) => b.priority - a.priority);
this.registry.set(name, list);
return () => {
this.unhook(name, handler);
};
}
public unhook<K extends keyof TMap>(
name: K,
handler: HookHandler<TMap[K]>
): void {
const list = this.registry.get(name);
if (!list) return;
const idx = list.findIndex((e) => e.handler === handler);
if (idx !== -1) {
list.splice(idx, 1);
}
if (list.length === 0) {
this.registry.delete(name);
}
}
public unhookAll(name?: keyof TMap): void {
if (name !== undefined) {
this.registry.delete(name);
} else {
this.registry.clear();
}
}
public applyHooks<K extends keyof TMap>(name: K, data: TMap[K]): TMap[K] {
const list = this.registry.get(name);
if (!list || list.length === 0) return data;
let current = data;
const toRemove: RegisteredHook<unknown>[] = [];
const sharedMeta: Record<string, unknown> = {};
const total = list.length;
for (const [i, entry] of list.entries()) {
const ctx = createHookContext(
name as string | symbol,
i,
total,
sharedMeta
);
const result = (entry.handler as HookHandler<TMap[K]>)(
current,
ctx
);
if (result !== undefined) {
if (isThenable(result)) {
throw new BookingError(
BOOKING_CODES.HOOK_RETURNED_PROMISE_IN_SYNC,
`Hook handler for "${String(name)}" returned a Promise in sync applyHooks(). Use applyHooksAsync() for async handlers.`
);
}
current = result;
}
if (entry.once) toRemove.push(entry);
if (ctx.aborted) break;
}
this.applyRemovals(list, toRemove);
return current;
}
public async applyHooksAsync<K extends keyof TMap>(
name: K,
data: TMap[K]
): Promise<TMap[K]> {
const list = this.registry.get(name);
if (!list || list.length === 0) return data;
let current = data;
const toRemove: RegisteredHook<unknown>[] = [];
const sharedMeta: Record<string, unknown> = {};
const total = list.length;
for (const [i, entry] of list.entries()) {
const ctx = createHookContext(
name as string | symbol,
i,
total,
sharedMeta
);
const result = await (entry.handler as HookHandler<TMap[K]>)(
current,
ctx
);
if (result !== undefined) current = result;
if (entry.once) toRemove.push(entry);
if (ctx.aborted) break;
}
this.applyRemovals(list, toRemove);
return current;
}
public async applyHooksWithResult<K extends keyof TMap>(
name: K,
data: TMap[K]
): Promise<HookResult<TMap[K]>> {
const list = this.registry.get(name);
if (!list || list.length === 0) {
return { data, aborted: false, handlersRun: 0, labels: [] };
}
let current = data;
const toRemove: RegisteredHook<unknown>[] = [];
const labels: string[] = [];
let handlersRun = 0;
let wasAborted = false;
const sharedMeta: Record<string, unknown> = {};
const total = list.length;
for (const [i, entry] of list.entries()) {
const ctx = createHookContext(
name as string | symbol,
i,
total,
sharedMeta
);
const result = await (entry.handler as HookHandler<TMap[K]>)(
current,
ctx
);
handlersRun++;
if (entry.label) labels.push(entry.label);
if (result !== undefined) current = result;
if (entry.once) toRemove.push(entry);
if (ctx.aborted) {
wasAborted = true;
break;
}
}
this.applyRemovals(list, toRemove);
return { data: current, aborted: wasAborted, handlersRun, labels };
}
private applyRemovals(
list: RegisteredHook<unknown>[],
toRemove: RegisteredHook<unknown>[]
): void {
// Removals are entries we collected during iteration over `list`,
// so they are guaranteed to be findable.
for (const entry of toRemove) {
list.splice(list.indexOf(entry), 1);
}
}
public hookNames(): (keyof TMap)[] {
return Array.from(this.registry.keys()) as (keyof TMap)[];
}
public hookCount(name: keyof TMap): number {
return this.registry.get(name)?.length ?? 0;
}
public hasHook(name: keyof TMap): boolean {
return (this.registry.get(name)?.length ?? 0) > 0;
}
}

View File

@@ -0,0 +1,89 @@
/**
* Type definitions for the in-package hookable transform pipeline.
*
* Hooks are like events, but handlers can transform the data flowing through
* them. Each handler in the chain receives the output of the previous one,
* similar to a transform-stream pipeline.
*/
export interface HookMap {
[key: string]: unknown;
}
export interface DefaultHookMap {
[key: string]: unknown;
}
/**
* A hook handler receives data, can transform it, and returns the result.
* Returning `undefined` means "pass through unchanged".
*/
export type HookHandler<T = unknown> = (
data: T,
context: HookContext
) => T | undefined | Promise<T | undefined>;
/** Synchronous hook handler variant. */
export type SyncHookHandler<T = unknown> = (
data: T,
context: HookContext
) => T | undefined;
/** Context passed to every hook handler. */
export interface HookContext {
readonly hook: string | symbol;
readonly index: number;
readonly total: number;
abort(): void;
readonly aborted: boolean;
readonly meta: Record<string, unknown>;
}
/** Options when registering a hook handler. */
export interface HookHandlerOptions {
priority?: number;
once?: boolean;
label?: string;
}
/** Internal representation of a registered hook handler. */
export interface RegisteredHook<T = unknown> {
handler: HookHandler<T>;
priority: number;
once: boolean;
label?: string;
}
/** Result of running a hook chain. */
export interface HookResult<T> {
data: T;
aborted: boolean;
handlersRun: number;
labels: string[];
}
/** Extract hook data type from a hook map. */
export type HookData<TMap extends HookMap, K extends keyof TMap> = TMap[K];
/** Typed hook handler for a specific hook in a map. */
export type TypedHookHandler<
TMap extends HookMap,
K extends keyof TMap,
> = HookHandler<TMap[K]>;
/** Interface for objects that support hooks. */
export interface Hookable<TMap extends HookMap = DefaultHookMap> {
hook<K extends keyof TMap>(
name: K,
handler: HookHandler<TMap[K]>,
options?: HookHandlerOptions
): () => void;
unhook<K extends keyof TMap>(name: K, handler: HookHandler<TMap[K]>): void;
applyHooks<K extends keyof TMap>(name: K, data: TMap[K]): TMap[K];
applyHooksAsync<K extends keyof TMap>(
name: K,
data: TMap[K]
): Promise<TMap[K]>;
hookNames(): (keyof TMap)[];
hookCount(name: keyof TMap): number;
}

View File

@@ -0,0 +1,14 @@
export { Hooks } from "./hooks.js";
export type {
DefaultHookMap,
Hookable,
HookContext,
HookData,
HookHandler,
HookHandlerOptions,
HookMap,
HookResult,
RegisteredHook,
SyncHookHandler,
TypedHookHandler,
} from "./hooks.types.js";

View File

@@ -0,0 +1,273 @@
// Error codes
// Availability
export {
clearStrategyRegistry,
getRegisteredStrategies,
registerAvailabilityStrategy,
resolveAvailability,
} from "./availability/resolver/resolver.js";
export type { AvailabilityStrategyFn } from "./availability/resolver/resolver.types.js";
export {
capacityStrategy,
exactMatchStrategy,
rangeOverlapStrategy,
} from "./availability/strategies/strategies.js";
export type { SlotAvailability } from "./availability/strategies/strategies.types.js";
// Conflict
export {
clearDetectorRegistry,
detectConflicts,
getRegisteredModes,
registerDetector,
} from "./conflict/detector/detector.js";
export type {
ConflictDetectionMode,
ConflictDetectorFn,
OverlapResult,
} from "./conflict/detector/detector.types.js";
export { detectExactMatch } from "./conflict/exact-match/exact-match.js";
export { detectProviderOverlap } from "./conflict/provider-overlap/provider-overlap.js";
export { detectRangeOverlap } from "./conflict/range-overlap/range-overlap.js";
export {
type CapacityContext,
detectSlotCapacity,
} from "./conflict/slot-capacity/slot-capacity.js";
// Constants
export {
DEFAULT_AVAILABILITY_REFRESH_MS,
DEFAULT_CACHE_TTL_MS,
DEFAULT_CURRENCY,
DEFAULT_DATE_FORMAT,
DEFAULT_FIRST_DAY_OF_WEEK,
DEFAULT_HOLD_DURATION_MINUTES,
DEFAULT_LOCALE,
DEFAULT_MAX_ADVANCE_DAYS,
DEFAULT_MAX_HOLD_DURATION_MINUTES,
DEFAULT_ROUNDING_MODE,
DEFAULT_ROUNDING_PRECISION,
DEFAULT_SCHEMA_VERSION,
DEFAULT_SLOT_DURATION_MS,
DEFAULT_TAX_STRATEGY,
DEFAULT_TIME_FORMAT,
} from "./constants/index.js";
// Core
export { BookingEngine, createBookingEngine } from "./core/engine/engine.js";
export type {
AvailabilityQuery,
AvailabilityResult,
BookingEngineInterface,
ConflictResult,
PriceCalculationRequest,
} from "./core/engine/engine.types.js";
export { createEventBus, EventBus } from "./core/event-bus/event-bus.js";
export {
createStateMachine,
StateMachine,
type TransitionRequest,
type TransitionResult,
} from "./core/state-machine/state-machine.js";
// Domains
export { createAccommodationConfig } from "./domains/accommodation/accommodation.js";
export type { AccommodationBookableMeta } from "./domains/accommodation/accommodation.types.js";
export { createActivityConfig } from "./domains/activities/activities.js";
export type { ActivityBookableMeta } from "./domains/activities/activities.types.js";
export { createAppointmentConfig } from "./domains/appointments/appointments.js";
export type { AppointmentBookableMeta } from "./domains/appointments/appointments.types.js";
export { createResourceConfig } from "./domains/resources/resources.js";
export type { ResourceBookableMeta } from "./domains/resources/resources.types.js";
export { createSeatConfig } from "./domains/seats/seats.js";
export type { SeatBookableMeta } from "./domains/seats/seats.types.js";
export { createBaseConfig } from "./domains/shared.js";
export { createTransportConfig } from "./domains/transport/transport.js";
export type { TransportBookableMeta } from "./domains/transport/transport.types.js";
export {
BOOKING_CODES,
BookingError,
type BookingErrorCode,
type BookingErrorOptions,
} from "./error-codes/error-codes.js";
// Hooks
export { Hooks } from "./hooks/hooks.js";
export type {
DefaultHookMap,
Hookable,
HookContext,
HookData,
HookHandler,
HookHandlerOptions,
HookMap,
HookResult,
RegisteredHook,
SyncHookHandler,
TypedHookHandler,
} from "./hooks/hooks.types.js";
// Pricing
export {
type ApplyModifiersResult,
applyModifiers,
applyTiers,
calculateBasePrice,
calculatePrice,
calculateTax,
} from "./pricing/calculator/calculator.js";
export type {
PriceBreakdownItem,
PriceCalculationContext,
PricingPipelineStep,
} from "./pricing/calculator/calculator.types.js";
export {
applyDynamicPricing,
calculateDynamicMultiplier,
createDynamicContext,
type DynamicPricingContext,
findApplicableDynamicRules,
} from "./pricing/dynamic/dynamic.js";
export {
applySeasonalRate,
calculateSeasonalMultiplier,
findApplicableRates,
getSeasonName,
} from "./pricing/seasonal/seasonal.js";
// Rules
export {
createAdvanceBookingCondition,
createAttributeCondition,
createCustomerLimitCondition,
createMaxDurationCondition,
createMinDurationCondition,
createQuantityRangeCondition,
createStatusCondition,
createWeekendCondition,
} from "./rules/conditions/conditions.js";
export { evaluateRule, evaluateRules } from "./rules/evaluator/evaluator.js";
export type {
ConditionEvaluatorFn,
EvaluationContext,
} from "./rules/evaluator/evaluator.types.js";
// Schemas
export {
bookableSchema,
bookingRequestSchema,
bookingSchema,
holdRequestSchema,
timeWindowSchema,
validateBookable,
validateBooking,
validateBookingRequest,
} from "./schemas/booking.schema.js";
export {
bookingSystemConfigSchema,
safeValidateConfig,
validateConfig,
} from "./schemas/config.schema.js";
export {
createRecurrenceRule,
expandRecurrence,
isRecurring,
} from "./temporal/recurrence/recurrence.js";
export {
filterAvailableSlots,
generateDailySlots,
generateTimeSlots,
reduceSlotCapacity,
} from "./temporal/slots/slots.js";
// Temporal
export {
createWindow,
isValidWindow,
mergeWindows,
splitWindow,
windowContains,
windowDurationMs,
windowsAdjacent,
windowsOverlap,
windowsOverlapWithBuffer,
} from "./temporal/windows/windows.js";
// Types (entity shapes)
export type {
Bookable,
BookableId,
BookableQuery,
BookableQueryResult,
} from "./types/bookable.types.js";
export type {
Booking,
BookingId,
BookingModification,
BookingRequest,
CancellationResult,
CancellationTerms,
HoldRequest,
WaitlistRequest,
} from "./types/booking.types.js";
export type {
BookingCancellingHandler,
BookingCancellingHookData,
BookingCreatingHandler,
BookingCreatingHookData,
BookingHookMap,
} from "./types/booking-hooks.types.js";
export type {
BookingHold,
CapacityPool,
OverbookingStrategy,
WaitlistEntry,
} from "./types/capacity.types.js";
export type {
AttributeDefinition,
AvailabilityConfig,
BookableTypeDefinition,
BookingSystemConfig,
ConflictDetectorConfig,
EngineConfig,
HoldConfig,
LayoutConfig,
LocaleConfig,
NotificationConfig,
NotificationTrigger,
PricingEngineConfig,
StatusTransition,
StrategyRegistration,
TenantConfig,
WaitlistConfig,
} from "./types/config.types.js";
export type {
BookingEvent,
BookingEventHandler,
BookingEventType,
EventSubscription,
} from "./types/events.types.js";
export type {
LayoutColorScheme,
LayoutNode,
NodePosition,
} from "./types/layout.types.js";
export type {
BookingPrice,
DynamicPricingRule,
PriceModifier,
PriceRule,
PriceTier,
SeasonalRate,
} from "./types/pricing.types.js";
export type {
BookingRule,
RuleAction,
RuleCondition,
RuleEvaluationResult,
} from "./types/rules.types.js";
export type {
RecurrenceRule,
TimeSlot,
TimeWindow,
} from "./types/temporal.types.js";
// Utils
export {
clampNumber,
deepFreeze,
generateId,
isNonEmptyString,
parseDurationMs,
} from "./utils/utils.js";

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,224 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
PriceModifier,
PriceRule,
PriceTier,
} from "../../types/pricing.types.js";
import {
applyModifiers,
applyTiers,
calculateBasePrice,
calculatePrice,
calculateTax,
} from "./calculator.js";
import type { PriceCalculationContext } from "./calculator.types.js";
const window = {
startTime: "2026-06-01T14:00:00Z",
endTime: "2026-06-03T14:00:00Z",
timezone: "UTC",
};
const rule = (
model: string,
amount = 100,
unitDuration = "P1D"
): PriceRule => ({
ruleId: `r-${model}`,
amount,
currency: "USD",
pricingModel: model,
unitDuration,
});
describe("calculator", () => {
it("pipeline runs base → tiers → modifiers → tax → rounding", {
timeout: 5000,
}, () => {
const ctx: PriceCalculationContext = {
bookableRef: "r",
windowRef: window,
quantity: 2,
rules: [rule("per_unit", 50)],
tiers: [],
modifiers: [],
currency: "USD",
taxStrategy: "exclusive",
roundingMode: "round",
roundingPrecision: 2,
};
const price = calculatePrice(ctx);
expect(price.subtotal).toBe(100);
expect(price.tax).toBe(10);
expect(price.total).toBe(110);
expect(price.breakdown).toEqual(["r-per_unit"]);
});
it("per_duration uses unitDuration", { timeout: 5000 }, () => {
const out = calculateBasePrice(
[rule("per_duration", 50, "P1D")],
1,
window
);
expect(out).toBe(100); // 2 days * 50
});
it("calculateBasePrice handles fixed / tiered / unknown / empty", {
timeout: 5000,
}, () => {
expect(calculateBasePrice([], 1, window)).toBe(0);
expect(calculateBasePrice([rule("fixed", 75)], 5, window)).toBe(75);
expect(calculateBasePrice([rule("tiered", 20)], 3, window)).toBe(60);
expect(calculateBasePrice([rule("weird", 10)], 4, window)).toBe(40);
});
it("per_duration falls back to one-day unit when unitDuration parses to 0", {
timeout: 5000,
}, () => {
const out = calculateBasePrice(
[rule("per_duration", 50, "")],
1,
window
);
expect(out).toBe(100); // duration 2 days, fallback unit 1 day
});
it("applyTiers picks the bracket matching quantity", {
timeout: 5000,
}, () => {
const tiers: PriceTier[] = [
{
tierId: "t1",
ruleRef: "r-tiered",
minQuantity: 1,
maxQuantity: 5,
amount: 10,
},
{
tierId: "t2",
ruleRef: "r-tiered",
minQuantity: 6,
maxQuantity: 20,
amount: 8,
},
];
expect(applyTiers(100, 7, tiers, ["r-tiered"])).toBe(56);
expect(applyTiers(100, 3, tiers, ["r-tiered"])).toBe(30);
});
it("applyTiers returns baseAmount when no relevant tiers", {
timeout: 5000,
}, () => {
expect(applyTiers(99, 3, [], ["r-x"])).toBe(99);
expect(
applyTiers(
99,
50,
[
{
tierId: "t1",
ruleRef: "r-x",
minQuantity: 1,
maxQuantity: 5,
amount: 10,
},
],
["r-x"]
)
).toBe(99);
});
it("applyModifiers classifies discount/surcharge/fee/tax", {
timeout: 5000,
}, () => {
const mods: PriceModifier[] = [
{
modifierId: "m-disc",
type: "discount",
calculationMethod: "percentage",
value: 10,
conditionRef: "",
},
{
modifierId: "m-fee",
type: "fee",
calculationMethod: "flat",
value: 5,
conditionRef: "",
},
{
modifierId: "m-surch",
type: "surcharge",
calculationMethod: "flat",
value: 2,
conditionRef: "",
},
{
modifierId: "m-tax",
type: "tax",
calculationMethod: "percentage",
value: 8,
conditionRef: "",
},
{
modifierId: "m-other",
type: "other",
calculationMethod: "flat",
value: 99,
conditionRef: "",
},
];
const result = applyModifiers(100, mods);
expect(result.adjusted).toBeCloseTo(100 - 10 + 2);
expect(result.fees).toBe(5);
expect(result.discount).toBe(10);
expect(result.applied).toEqual([
"m-disc",
"m-fee",
"m-surch",
"m-tax",
"m-other",
]);
});
it("calculateTax uses tax modifiers when present, else strategy default", {
timeout: 5000,
}, () => {
const mods: PriceModifier[] = [
{
modifierId: "m-tax",
type: "tax",
calculationMethod: "percentage",
value: 8,
conditionRef: "",
},
{
modifierId: "m-tax2",
type: "tax",
calculationMethod: "flat",
value: 3,
conditionRef: "",
},
];
expect(calculateTax(100, "exclusive", mods)).toBeCloseTo(11);
expect(calculateTax(100, "exclusive", [])).toBe(10);
expect(calculateTax(100, "inclusive", [])).toBe(0);
});
it("rounding honours round/floor/ceil", { timeout: 5000 }, () => {
const base = (mode: string): PriceCalculationContext => ({
bookableRef: "r",
windowRef: window,
quantity: 1,
rules: [rule("fixed", 100.555)],
tiers: [],
modifiers: [],
currency: "USD",
taxStrategy: "inclusive",
roundingMode: mode,
roundingPrecision: 2,
});
expect(calculatePrice(base("round")).total).toBe(100.56);
expect(calculatePrice(base("floor")).total).toBe(100.55);
expect(calculatePrice(base("ceil")).total).toBe(100.56);
});
});

View File

@@ -0,0 +1,173 @@
/**
* Price-calculation pipeline. Processes base price through tiers, modifiers,
* tax, and rounding.
*/
import type {
BookingPrice,
PriceModifier,
PriceRule,
PriceTier,
} from "../../types/pricing.types.js";
import type { TimeWindow } from "../../types/temporal.types.js";
import { parseDurationMs } from "../../utils/utils.js";
import type { PriceCalculationContext } from "./calculator.types.js";
function roundPrice(value: number, precision: number, mode: string): number {
const factor = 10 ** precision;
switch (mode) {
case "floor":
return Math.floor(value * factor) / factor;
case "ceil":
return Math.ceil(value * factor) / factor;
default:
return Math.round(value * factor) / factor;
}
}
export function calculateBasePrice(
rules: PriceRule[],
quantity: number,
windowRef: TimeWindow
): number {
if (rules.length === 0) return 0;
let total = 0;
for (const rule of rules) {
switch (rule.pricingModel) {
case "fixed":
total += rule.amount;
break;
case "per_unit":
total += rule.amount * quantity;
break;
case "per_duration": {
const durationMs =
new Date(windowRef.endTime).getTime() -
new Date(windowRef.startTime).getTime();
const unitMs = parseDurationMs(rule.unitDuration) || 86400000;
const units = Math.ceil(durationMs / unitMs);
total += rule.amount * units;
break;
}
case "tiered":
total += rule.amount * quantity;
break;
default:
total += rule.amount * quantity;
}
}
return total;
}
export function applyTiers(
baseAmount: number,
quantity: number,
tiers: PriceTier[],
ruleRefs: string[]
): number {
const relevantTiers = tiers
.filter((t) => ruleRefs.includes(t.ruleRef))
.sort((a, b) => a.minQuantity - b.minQuantity);
if (relevantTiers.length === 0) return baseAmount;
for (const tier of relevantTiers) {
if (quantity >= tier.minQuantity && quantity <= tier.maxQuantity) {
return tier.amount * quantity;
}
}
return baseAmount;
}
export interface ApplyModifiersResult {
adjusted: number;
fees: number;
discount: number;
applied: string[];
}
export function applyModifiers(
subtotal: number,
modifiers: PriceModifier[]
): ApplyModifiersResult {
let adjusted = subtotal;
let fees = 0;
let discount = 0;
const applied: string[] = [];
for (const mod of modifiers) {
const amount =
mod.calculationMethod === "percentage"
? subtotal * (mod.value / 100)
: mod.value;
switch (mod.type) {
case "discount":
discount += amount;
adjusted -= amount;
break;
case "surcharge":
adjusted += amount;
break;
case "fee":
fees += amount;
break;
case "tax":
break;
default:
break;
}
applied.push(mod.modifierId);
}
return { adjusted, fees, discount, applied };
}
export function calculateTax(
subtotal: number,
taxStrategy: string,
modifiers: PriceModifier[]
): number {
const taxMods = modifiers.filter((m) => m.type === "tax");
if (taxMods.length === 0) {
return taxStrategy === "exclusive" ? subtotal * 0.1 : 0;
}
let tax = 0;
for (const mod of taxMods) {
tax +=
mod.calculationMethod === "percentage"
? subtotal * (mod.value / 100)
: mod.value;
}
return tax;
}
export function calculatePrice(context: PriceCalculationContext): BookingPrice {
const { rules, quantity, windowRef, tiers, modifiers, currency } = context;
const { taxStrategy, roundingMode, roundingPrecision } = context;
let subtotal = calculateBasePrice(rules, quantity, windowRef);
const ruleRefs = rules.map((r) => r.ruleId);
subtotal = applyTiers(subtotal, quantity, tiers, ruleRefs);
const modResult = applyModifiers(subtotal, modifiers);
subtotal = modResult.adjusted;
const tax = calculateTax(subtotal, taxStrategy, modifiers);
const total = subtotal + tax + modResult.fees;
return {
subtotal: roundPrice(subtotal, roundingPrecision, roundingMode),
tax: roundPrice(tax, roundingPrecision, roundingMode),
fees: roundPrice(modResult.fees, roundingPrecision, roundingMode),
discount: roundPrice(
modResult.discount,
roundingPrecision,
roundingMode
),
total: roundPrice(total, roundingPrecision, roundingMode),
currency,
breakdown: [...ruleRefs, ...modResult.applied],
};
}

View File

@@ -0,0 +1,35 @@
/**
* Calculator-specific type definitions.
*/
import type {
BookingPrice,
PriceModifier,
PriceRule,
PriceTier,
} from "../../types/pricing.types.js";
import type { TimeWindow } from "../../types/temporal.types.js";
export interface PriceCalculationContext {
bookableRef: string;
windowRef: TimeWindow;
quantity: number;
rules: PriceRule[];
tiers: PriceTier[];
modifiers: PriceModifier[];
currency: string;
taxStrategy: string;
roundingMode: string;
roundingPrecision: number;
}
export interface PriceBreakdownItem {
label: string;
amount: number;
type: string;
}
export type PricingPipelineStep = (
context: PriceCalculationContext,
currentPrice: BookingPrice
) => BookingPrice;

View File

@@ -0,0 +1,13 @@
export {
type ApplyModifiersResult,
applyModifiers,
applyTiers,
calculateBasePrice,
calculatePrice,
calculateTax,
} from "./calculator.js";
export type {
PriceBreakdownItem,
PriceCalculationContext,
PricingPipelineStep,
} from "./calculator.types.js";

Some files were not shown because too many files have changed in this diff Show More