# Capability Spec Delta: booking (add-booking) **Package**: `@suaveplan/booking` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: Typed error hierarchy The package SHALL expose a `BookingError` class extending `SuaveplanError` and a `BOOKING_CODES` registry created via `defineCodes("BOOKING", [...])`. Every failure path raised from the package's public surface MUST throw a `BookingError` (never plain `Error`), and the registry MUST cover at minimum: `ENGINE_NOT_CONFIGURED`, `BOOKING_NOT_FOUND`, `BOOKING_CONFLICT`, `TRANSITION_NOT_ALLOWED`, `UNKNOWN_CONFLICT_MODE`, `UNKNOWN_AVAILABILITY_STRATEGY`, `HOOK_RETURNED_PROMISE_IN_SYNC`. #### Scenario: every registered code is reachable from a production path - **WHEN** the test suite walks every entry in `BOOKING_CODES` - **THEN** at least one production call site raises a `BookingError` carrying that code #### Scenario: discriminants narrow on instanceof - **WHEN** a caller catches a thrown error and uses `instanceof BookingError` - **THEN** TypeScript narrows `error.code` to `BookingErrorCode` and optional `bookingId`/`holdId`/`mode`/`strategy` discriminants are readable **Verified by:** `src/error-codes/error-codes.test.ts::"BOOKING_CODES contains every documented code"` AND `src/error-codes/error-codes.test.ts::"every code is reachable from a production path"` AND `src/error-codes/error-codes.test.ts::"BookingError carries optional discriminants"` --- ### Requirement: In-package data-transform Hooks pipeline The package SHALL ship a `Hooks` class under `src/hooks/` that supports registering handlers with priority + once + label options, synchronous and asynchronous chain execution, abort, and detailed result reporting. The synchronous path MUST throw a `BookingError({ code: BOOKING.HOOK_RETURNED_PROMISE_IN_SYNC })` if a handler returns a thenable while running under `applyHooks` (replacing the upstream `console.warn`). #### Scenario: handlers transform data in priority order - **WHEN** two handlers are registered for `"booking:creating"` with `priority: 10` and `priority: 0` - **THEN** the priority-10 handler runs first and its return value is passed as input to the priority-0 handler #### Scenario: returning undefined passes data through - **WHEN** a handler returns `undefined` - **THEN** the next handler in the chain receives the data unchanged #### Scenario: abort stops the chain - **WHEN** a handler calls `context.abort()` - **THEN** no further handlers run and `applyHooksWithResult` reports `aborted: true` #### Scenario: once handlers auto-remove - **WHEN** a handler is registered with `{ once: true }` and the chain runs once - **THEN** `hookCount` for that hook decreases by one and a second `applyHooks` does not invoke the handler #### Scenario: async return inside sync apply is rejected - **WHEN** a handler returns a `Promise` from inside `applyHooks` (the sync entry point) - **THEN** a `BookingError({ code: BOOKING.HOOK_RETURNED_PROMISE_IN_SYNC })` is thrown **Verified by:** `src/hooks/hooks.test.ts::"runs handlers in priority order"` AND `src/hooks/hooks.test.ts::"returning undefined passes data through"` AND `src/hooks/hooks.test.ts::"context.abort stops the chain"` AND `src/hooks/hooks.test.ts::"once handlers auto-remove"` AND `src/hooks/hooks.test.ts::"sync applyHooks rejects async return"` AND `src/hooks/hooks.test.ts::"applyHooksAsync awaits async handlers"` AND `src/hooks/hooks.test.ts::"hookCount and hasHook reflect registry state"` --- ### Requirement: Temporal window operations The package SHALL expose `windowsOverlap`, `windowsOverlapWithBuffer`, `mergeWindows`, `splitWindow`, `windowDurationMs`, `windowContains`, `createWindow`, `isValidWindow`, `windowsAdjacent` with half-open-interval semantics and an optional boundary-turnover flag for `windowsOverlap`. #### Scenario: half-open overlap - **WHEN** two windows share an exact boundary (`a.endTime === b.startTime`) - **THEN** `windowsOverlap(a, b)` returns `false` (half-open intervals do not overlap at boundaries) #### Scenario: buffer-extended overlap - **WHEN** `windowsOverlapWithBuffer(proposed, existing, 3600000)` is called and `existing` is within one hour of `proposed` - **THEN** the call returns `true` #### Scenario: merge collapses adjacent windows - **WHEN** `mergeWindows([w1, w2])` is called and `w1.endTime === w2.startTime` - **THEN** the result contains a single window spanning `w1.startTime` to `w2.endTime` **Verified by:** `src/temporal/windows/windows.test.ts::"windowsOverlap respects half-open intervals"` AND `src/temporal/windows/windows.test.ts::"windowsOverlap with boundary turnover excludes touching windows"` AND `src/temporal/windows/windows.test.ts::"windowsOverlapWithBuffer extends existing"` AND `src/temporal/windows/windows.test.ts::"mergeWindows collapses adjacent"` AND `src/temporal/windows/windows.test.ts::"splitWindow produces fixed-interval slices"` AND `src/temporal/windows/windows.test.ts::"windowContains/windowDurationMs/windowsAdjacent/createWindow/isValidWindow behave as documented"` --- ### Requirement: Recurrence expansion The package SHALL expose `createRecurrenceRule`, `isRecurring`, `expandRecurrence` covering daily, weekly, monthly, yearly frequencies with `daysOfWeek` filtering, `exceptions` date matching, and a hard 1000-iteration guard. #### Scenario: weekly with daysOfWeek filters - **WHEN** `expandRecurrence` is given a weekly rule with `daysOfWeek: [1, 3]` over a 3-week window - **THEN** only Monday and Wednesday occurrences appear in the result #### Scenario: exception dates exclude matching occurrences - **WHEN** the recurrence rule's `exceptions` list contains a date inside the expansion window - **THEN** no occurrence is emitted for that date **Verified by:** `src/temporal/recurrence/recurrence.test.ts::"expands daily / weekly / monthly / yearly frequencies"` AND `src/temporal/recurrence/recurrence.test.ts::"weekly daysOfWeek filters"` AND `src/temporal/recurrence/recurrence.test.ts::"exception dates exclude matching occurrences"` AND `src/temporal/recurrence/recurrence.test.ts::"isRecurring and createRecurrenceRule helpers"` AND `src/temporal/recurrence/recurrence.test.ts::"hard iteration guard caps runaway rules"` --- ### Requirement: Time-slot generation The package SHALL expose `generateTimeSlots`, `generateDailySlots`, `filterAvailableSlots`, `reduceSlotCapacity`. `generateTimeSlots` only emits slots whose end fits inside the window; `reduceSlotCapacity` floors capacity at zero. #### Scenario: slot capacity reduces but cannot go negative - **WHEN** `reduceSlotCapacity(slot, slot.capacityRemaining + 5)` is called - **THEN** the returned slot's `capacityRemaining` is `0` **Verified by:** `src/temporal/slots/slots.test.ts::"generateTimeSlots emits only fitting slots"` AND `src/temporal/slots/slots.test.ts::"generateDailySlots emits per-day windows"` AND `src/temporal/slots/slots.test.ts::"filterAvailableSlots applies minimum capacity"` AND `src/temporal/slots/slots.test.ts::"reduceSlotCapacity floors at zero"` --- ### Requirement: Conflict-detection registry and four built-in modes The package SHALL expose `registerDetector`, `detectConflicts`, `getRegisteredModes`, `clearDetectorRegistry`. `detectConflicts` MUST raise `BookingError({ code: BOOKING.UNKNOWN_CONFLICT_MODE, mode })` when the configured mode is not registered. Four built-ins MUST exist: `detectExactMatch`, `detectRangeOverlap`, `detectSlotCapacity`, `detectProviderOverlap`. `detectRangeOverlap` MUST honour both `bufferDuration` and `allowSameBoundaryTurnover`. #### Scenario: unknown mode raises typed error - **WHEN** `detectConflicts(proposed, [], { mode: "made-up", … })` is called with no detector registered for `made-up` - **THEN** a `BookingError({ code: BOOKING.UNKNOWN_CONFLICT_MODE, mode: "made-up" })` is thrown #### Scenario: range overlap respects boundary turnover - **WHEN** `detectRangeOverlap` is called with `allowSameBoundaryTurnover: true` and the existing window ends exactly at the proposed start - **THEN** the result reports no conflict for the boundary-touch case #### Scenario: provider overlap honours buffer - **WHEN** `detectProviderOverlap` is called with `bufferDuration: "PT15M"` and an existing booking ending 10 minutes before the proposed start - **THEN** the existing booking is reported as conflicting **Verified by:** `src/conflict/detector/detector.test.ts::"unknown mode raises BOOKING.UNKNOWN_CONFLICT_MODE"` AND `src/conflict/detector/detector.test.ts::"register/get/clear lifecycle"` AND `src/conflict/exact-match/exact-match.test.ts::"matches only on exact start+end"` AND `src/conflict/range-overlap/range-overlap.test.ts::"detects overlap with buffer"` AND `src/conflict/range-overlap/range-overlap.test.ts::"allowSameBoundaryTurnover excludes boundary-touching"` AND `src/conflict/slot-capacity/slot-capacity.test.ts::"reports conflict when total exceeds capacity"` AND `src/conflict/provider-overlap/provider-overlap.test.ts::"honours bufferDuration"` --- ### Requirement: Pricing pipeline The package SHALL expose `calculatePrice`, `calculateBasePrice`, `applyTiers`, `applyModifiers`, `calculateTax` (calculator), `applySeasonalRate`, `calculateSeasonalMultiplier`, `findApplicableRates`, `getSeasonName` (seasonal), `applyDynamicPricing`, `calculateDynamicMultiplier`, `createDynamicContext`, `findApplicableDynamicRules` (dynamic). `calculateBasePrice` MUST handle `fixed`, `per_unit`, `per_duration`, `tiered`, and unknown pricing models (unknown falls back to per-unit). `applyModifiers` MUST classify `discount` / `surcharge` / `fee` / `tax` modifiers. Rounding MUST honour `round` / `floor` / `ceil` modes. #### Scenario: tiered tier picks the bracket matching quantity - **WHEN** `applyTiers` is called with tiers `[{min:1,max:5,amount:10},{min:6,max:20,amount:8}]` and quantity 7 - **THEN** the result is `7 * 8 = 56` #### Scenario: dynamic rules trigger on threshold - **WHEN** `findApplicableDynamicRules` is called with `occupancyRate: 0.9` and a rule `{triggerType:"occupancy_threshold", threshold:0.8, multiplier:1.5}` - **THEN** the rule appears in the result #### Scenario: seasonal wrap-around-year window - **WHEN** `findApplicableRates` is called with a season `Dec 1 → Feb 28` and a window in January - **THEN** the season is reported as applicable **Verified by:** `src/pricing/calculator/calculator.test.ts::"pipeline runs base → tiers → modifiers → tax → rounding"` AND `src/pricing/calculator/calculator.test.ts::"per_duration uses unitDuration"` AND `src/pricing/calculator/calculator.test.ts::"applyModifiers classifies discount/surcharge/fee/tax"` AND `src/pricing/calculator/calculator.test.ts::"rounding honours round/floor/ceil"` AND `src/pricing/seasonal/seasonal.test.ts::"wrap-around-year season matches January window"` AND `src/pricing/seasonal/seasonal.test.ts::"getSeasonName returns first applicable"` AND `src/pricing/dynamic/dynamic.test.ts::"occupancy_threshold / advance_days / demand_score triggers"` AND `src/pricing/dynamic/dynamic.test.ts::"applyDynamicPricing multiplies"` --- ### Requirement: Rule evaluator and condition factories The package SHALL expose `evaluateRule`, `evaluateRules` (`all` / `any` logic, priority sort, modifier application, deny action), and eight built-in condition factories (`createMinDurationCondition`, `createMaxDurationCondition`, `createAdvanceBookingCondition`, `createQuantityRangeCondition`, `createAttributeCondition`, `createWeekendCondition`, `createStatusCondition`, `createCustomerLimitCondition`). The evaluator MUST support 11 operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `between`, `regex`, and an unknown-operator default returning `false`. #### Scenario: deny action collects failed rule - **WHEN** an enabled rule with `actionRef.actionType: "deny"` matches its conditions - **THEN** `evaluateRules` reports `passed: false` and includes the rule in `failedRules` #### Scenario: any vs all condition logic - **WHEN** `evaluateRule` is called with `conditionLogic: "any"` and at least one condition matches - **THEN** the rule evaluates true even when other conditions fail **Verified by:** `src/rules/evaluator/evaluator.test.ts::"11 operators each return documented result"` AND `src/rules/evaluator/evaluator.test.ts::"any vs all condition logic"` AND `src/rules/evaluator/evaluator.test.ts::"deny action collects failed rule"` AND `src/rules/evaluator/evaluator.test.ts::"apply_modifier action records modifierRef"` AND `src/rules/conditions/conditions.test.ts::"every factory produces the documented condition shape"` --- ### Requirement: Availability resolver registry and three built-in strategies The package SHALL expose `registerAvailabilityStrategy`, `resolveAvailability`, `getRegisteredStrategies`, `clearStrategyRegistry`. `resolveAvailability` MUST raise `BookingError({ code: BOOKING.UNKNOWN_AVAILABILITY_STRATEGY, strategy })` when the strategy id is not registered. Three built-ins MUST exist: `exactMatchStrategy`, `rangeOverlapStrategy`, `capacityStrategy`. #### Scenario: unknown strategy raises typed error - **WHEN** `resolveAvailability("nope", bookable, query, [])` is called with no `nope` strategy registered - **THEN** a `BookingError({ code: BOOKING.UNKNOWN_AVAILABILITY_STRATEGY, strategy: "nope" })` is thrown #### Scenario: capacity strategy honours quantity request - **WHEN** `capacityStrategy` is called with overlapping bookings totalling 95 and a request quantity 10 - **THEN** the result reports `available: false` and `remainingCapacity: 5` **Verified by:** `src/availability/resolver/resolver.test.ts::"unknown strategy raises BOOKING.UNKNOWN_AVAILABILITY_STRATEGY"` AND `src/availability/resolver/resolver.test.ts::"register/get/clear lifecycle"` AND `src/availability/strategies/strategies.test.ts::"exactMatchStrategy / rangeOverlapStrategy / capacityStrategy behave as documented"` --- ### Requirement: Six domain presets The package SHALL expose `createBaseConfig` plus six preset factories — `createSeatConfig`, `createAccommodationConfig`, `createAppointmentConfig`, `createResourceConfig`, `createActivityConfig`, `createTransportConfig` — each returning a fully populated `BookingSystemConfig` whose `engineRef.domainPreset` matches the preset name, whose `conflictRef.mode` matches the upstream default, and whose `bookableTypeRegistry` includes the upstream required/optional attributes verbatim. #### Scenario: overrides win over preset defaults - **WHEN** `createSeatConfig("t", "T", { engineRef: { ...defaults, maxBookingsPerCustomer: 5 } })` is called - **THEN** the returned config's `engineRef.maxBookingsPerCustomer` is `5` **Verified by:** `src/domains/shared.test.ts::"createBaseConfig populates every field with documented defaults"` AND `src/domains/seats/seats.test.ts::"createSeatConfig sets domainPreset and exact_match mode"` AND `src/domains/accommodation/accommodation.test.ts::"createAccommodationConfig sets range_overlap with PT4H buffer"` AND `src/domains/appointments/appointments.test.ts::"createAppointmentConfig sets provider_overlap with PT15M buffer"` AND `src/domains/resources/resources.test.ts::"createResourceConfig sets slot_capacity"` AND `src/domains/activities/activities.test.ts::"createActivityConfig sets slot_capacity and enableWaitlist"` AND `src/domains/transport/transport.test.ts::"createTransportConfig sets exact_match and enableWaitlist"` AND `src/domains/shared.test.ts::"overrides win over preset defaults"` --- ### Requirement: Zod schemas at validation boundaries The package SHALL expose `bookableSchema`, `bookingSchema`, `bookingRequestSchema`, `holdRequestSchema`, `timeWindowSchema`, `validateBookable`, `validateBooking`, `validateBookingRequest`, `bookingSystemConfigSchema`, `validateConfig`, `safeValidateConfig`. `validate*` MUST throw on invalid input; `safeValidateConfig` MUST return a `{success, data|error}` discriminated result. #### Scenario: validateBooking rejects malformed datetime - **WHEN** `validateBooking` is called with `windowRef.startTime` set to an invalid date string - **THEN** a Zod parse error is thrown #### Scenario: safeValidateConfig returns success false on bad shape - **WHEN** `safeValidateConfig({})` is called - **THEN** the result is `{ success: false, error: ZodError }` **Verified by:** `src/schemas/booking.schema.test.ts::"validates well-formed booking, bookable, request, hold"` AND `src/schemas/booking.schema.test.ts::"validateBooking rejects malformed datetime"` AND `src/schemas/config.schema.test.ts::"validates a populated BookingSystemConfig"` AND `src/schemas/config.schema.test.ts::"safeValidateConfig returns success:false on bad shape"` --- ### Requirement: Booking engine orchestration The package SHALL expose `BookingEngine` and `createBookingEngine` factory. The engine MUST throw `BookingError({ code: BOOKING.ENGINE_NOT_CONFIGURED })` when any orchestration method is called before `configure`; MUST throw `BookingError({ code: BOOKING.BOOKING_NOT_FOUND, bookingId })` when modifying or cancelling an unknown id; MUST throw `BookingError({ code: BOOKING.BOOKING_CONFLICT, bookingId? })` when `confirmBooking` finds overlapping bookings; MUST run `booking:creating` and `booking:cancelling` hooks through the in-package `Hooks` pipeline; and MUST emit lifecycle events through the `EventBus` (`hold:created`, `booking:confirmed`, `booking:cancelled`, `booking:modified`, `waitlist:joined`). #### Scenario: orchestration before configure throws - **WHEN** `engine.registerBookable(b)` is called before `engine.configure(cfg)` - **THEN** a `BookingError({ code: BOOKING.ENGINE_NOT_CONFIGURED })` is thrown #### Scenario: confirmBooking surfaces conflict as typed error - **WHEN** `engine.confirmBooking(req)` is called and an overlapping booking exists - **THEN** a `BookingError({ code: BOOKING.BOOKING_CONFLICT })` is thrown #### Scenario: hook transforms reach the persisted booking - **WHEN** a `booking:creating` hook lowercases `customerRef` and `engine.confirmBooking(req)` runs - **THEN** the persisted `Booking.customerRef` reflects the lowercased value #### Scenario: cancellation respects the state machine - **WHEN** `engine.cancelBooking` is called for a booking whose status has no `→ cancelled` transition allowed for `customer` - **THEN** a `BookingError({ code: BOOKING.TRANSITION_NOT_ALLOWED, bookingId })` is thrown **Verified by:** `src/core/engine/engine.test.ts::"orchestration before configure raises BOOKING.ENGINE_NOT_CONFIGURED"` AND `src/core/engine/engine.test.ts::"confirmBooking surfaces conflict as BOOKING.BOOKING_CONFLICT"` AND `src/core/engine/engine.test.ts::"cancelBooking raises BOOKING.BOOKING_NOT_FOUND for missing id"` AND `src/core/engine/engine.test.ts::"cancelBooking enforces state machine and raises BOOKING.TRANSITION_NOT_ALLOWED"` AND `src/core/engine/engine.test.ts::"hook transforms reach the persisted booking"` AND `src/core/engine/engine.test.ts::"hold and booking lifecycle emit events"` AND `src/core/engine/engine.test.ts::"checkAvailability/getAvailableSlots/listBookables behave as documented"` AND `src/core/engine/engine.test.ts::"calculatePrice rounds per pricingRef config"` AND `src/core/engine/engine.test.ts::"joinWaitlist appends entries"` --- ### Requirement: Status state machine The package SHALL expose `StateMachine` and `createStateMachine`. `canTransition` MUST report disallowed cases with a message identifying whether the transition is undefined, role-restricted, or requires a reason. `getAvailableTransitions(from, role)` and `getAutoTransitions(from)` MUST filter the configured `StatusTransition[]` correctly. #### Scenario: requiresReason without reason is disallowed - **WHEN** `canTransition({from:"confirmed", to:"cancelled", role:"customer"})` is called for a transition whose `requiresReason: true` - **THEN** the result is `{ allowed: false }` and the message names the missing reason **Verified by:** `src/core/state-machine/state-machine.test.ts::"undefined transitions are disallowed"` AND `src/core/state-machine/state-machine.test.ts::"role-restricted transitions are disallowed for wrong role"` AND `src/core/state-machine/state-machine.test.ts::"requiresReason without reason is disallowed"` AND `src/core/state-machine/state-machine.test.ts::"getAvailableTransitions / getAutoTransitions / getAllStatuses filter correctly"` --- ### Requirement: Typed booking event bus The package SHALL expose `EventBus` and `createEventBus`. `on` MUST return a unique subscription id, `off` MUST accept either the id or the handler reference, `emit` MUST wrap the payload into a `BookingEvent { eventType, timestamp, tenantId, payload }`, `clear` MUST drop every subscription, and `listenerCount`/`eventTypes` MUST report the current registry. #### Scenario: off by id and off by handler both unsubscribe - **WHEN** `on(t, h)` returns `id`, then `off(t, id)` is called - **THEN** the handler does not receive subsequent emits, and `listenerCount(t)` is zero **Verified by:** `src/core/event-bus/event-bus.test.ts::"emit wraps payload into BookingEvent"` AND `src/core/event-bus/event-bus.test.ts::"off by id removes the subscription"` AND `src/core/event-bus/event-bus.test.ts::"off by handler removes the subscription"` AND `src/core/event-bus/event-bus.test.ts::"clear drops every subscription"` AND `src/core/event-bus/event-bus.test.ts::"listenerCount and eventTypes report registry state"` --- ### Requirement: Utility helpers The package SHALL expose `parseDurationMs` parsing ISO-8601 durations (`P…D…T…H…M…S`), `clampNumber`, `deepFreeze` (recursive), `generateId` (prefix-suffix base36), `isNonEmptyString`. `parseDurationMs("")` MUST return `0`. `deepFreeze` MUST recurse into nested objects. #### Scenario: parseDurationMs handles compound duration - **WHEN** `parseDurationMs("P1DT2H30M")` is called - **THEN** the result is `86400000 + 7200000 + 1800000` **Verified by:** `src/utils/utils.test.ts::"parseDurationMs handles empty / date-only / time-only / compound"` AND `src/utils/utils.test.ts::"clampNumber clamps to range"` AND `src/utils/utils.test.ts::"deepFreeze recurses"` AND `src/utils/utils.test.ts::"generateId produces unique prefixed ids"` AND `src/utils/utils.test.ts::"isNonEmptyString narrows correctly"` --- ### Requirement: Tier purity and subpath exports The package SHALL be universal-tier (no browser, Node, or React APIs in `src/`) and SHALL ship one subpath export per public feature module so consumers tree-shake. The root barrel `src/index.ts` MUST export every public symbol explicitly (no `export *`); every feature-module `index.ts` MUST use explicit named re-exports. #### Scenario: each subpath resolves to the module's built artifacts - **WHEN** the build runs and consumers import via a documented subpath (e.g. `@suaveplan/booking/pricing/calculator/calculator`) - **THEN** only that module's bundle is loaded (verified by package.json `exports` map shape and build output) **Verified by:** `src/conformance.spec-conformance.test.ts::"every src//index.ts uses explicit named exports"` AND `src/conformance.spec-conformance.test.ts::"every public subpath is declared in package.json exports"` AND `src/conformance.spec-conformance.test.ts::"src tree contains no browser/Node-only globals"`