Files
2026-05-27 23:29:53 +01:00

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 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"