Files
suaveplan/openspec/specs/booking/spec.md
2026-05-27 23:29:53 +01:00

309 lines
23 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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<BookingErrorCode>` 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<TMap extends HookMap>` 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<TBookableMeta, TBookingMeta>` 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<BookingHookMap>` 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/<feature>/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"`
<!-- markdownlint-enable MD024 -->