23 KiB
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
BookingErrorcarrying that code
Scenario: discriminants narrow on instanceof
- WHEN a caller catches a thrown error and uses
instanceof BookingError - THEN TypeScript narrows
error.codetoBookingErrorCodeand optionalbookingId/holdId/mode/strategydiscriminants 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"withpriority: 10andpriority: 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
applyHooksWithResultreportsaborted: true
Scenario: once handlers auto-remove
- WHEN a handler is registered with
{ once: true }and the chain runs once - THEN
hookCountfor that hook decreases by one and a secondapplyHooksdoes not invoke the handler
Scenario: async return inside sync apply is rejected
- WHEN a handler returns a
Promisefrom insideapplyHooks(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)returnsfalse(half-open intervals do not overlap at boundaries)
Scenario: buffer-extended overlap
- WHEN
windowsOverlapWithBuffer(proposed, existing, 3600000)is called andexistingis within one hour ofproposed - THEN the call returns
true
Scenario: merge collapses adjacent windows
- WHEN
mergeWindows([w1, w2])is called andw1.endTime === w2.startTime - THEN the result contains a single window spanning
w1.startTimetow2.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
expandRecurrenceis given a weekly rule withdaysOfWeek: [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
exceptionslist 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
capacityRemainingis0
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 formade-up - THEN a
BookingError({ code: BOOKING.UNKNOWN_CONFLICT_MODE, mode: "made-up" })is thrown
Scenario: range overlap respects boundary turnover
- WHEN
detectRangeOverlapis called withallowSameBoundaryTurnover: trueand 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
detectProviderOverlapis called withbufferDuration: "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
applyTiersis 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
findApplicableDynamicRulesis called withoccupancyRate: 0.9and 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
findApplicableRatesis called with a seasonDec 1 → Feb 28and 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
evaluateRulesreportspassed: falseand includes the rule infailedRules
Scenario: any vs all condition logic
- WHEN
evaluateRuleis called withconditionLogic: "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 nonopestrategy registered - THEN a
BookingError({ code: BOOKING.UNKNOWN_AVAILABILITY_STRATEGY, strategy: "nope" })is thrown
Scenario: capacity strategy honours quantity request
- WHEN
capacityStrategyis called with overlapping bookings totalling 95 and a request quantity 10 - THEN the result reports
available: falseandremainingCapacity: 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.maxBookingsPerCustomeris5
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
validateBookingis called withwindowRef.startTimeset 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 beforeengine.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:creatinghook lowercasescustomerRefandengine.confirmBooking(req)runs - THEN the persisted
Booking.customerRefreflects the lowercased value
Scenario: cancellation respects the state machine
- WHEN
engine.cancelBookingis called for a booking whose status has no→ cancelledtransition allowed forcustomer - 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 whoserequiresReason: 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)returnsid, thenoff(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
exportsmap 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"