ExBooking (ExBooking v0.1.0)
View SourcePure booking decisions for scheduling products.
ExBooking is the facade for availability search, request validation,
assignment, lifecycle transitions, and small calendar-data normalizers. The
caller owns state and effects; this library only returns facts, events, and
intents from explicit inputs.
The important rule is that time is always supplied. Functions that depend on "now" require it in options, which makes decisions repeatable in tests, background jobs, and replayed workflows.
Example
iex> meeting_type = %ExBooking.MeetingType{
...> id: "intro",
...> duration_min: 30,
...> slot_interval_min: 15
...> }
...>
...> resource = %ExBooking.Resource{id: "resource_1", timezone: "Etc/UTC"}
...>
...> rule = %ExBooking.AvailabilityRule{
...> timezone: "Etc/UTC",
...> windows: [%{weekday: 1, start_time: ~T[09:00:00], end_time: ~T[10:00:00]}]
...> }
...>
...> {:ok, slots} =
...> ExBooking.available_slots(meeting_type, [resource], [rule],
...> now: ~U[2026-07-08 12:00:00Z],
...> from: ~U[2026-07-13 00:00:00Z],
...> until: ~U[2026-07-13 23:59:59Z]
...> )
...>
...> Enum.map(slots, & &1.start_at)
[~U[2026-07-13 09:00:00Z], ~U[2026-07-13 09:15:00Z], ~U[2026-07-13 09:30:00Z]]
Summary
Functions
Standalone assignment over pre-filtered free resources, for consumers that
run their own availability search. See ExBooking.Assignment.
Runs availability search and returns bookable slots sorted ascending by start.
Computes the pure cancellation transition for an existing booking.
The core entry point: validate, assign, and produce a full
ExBooking.Decision with events and side-effect intents.
Pure cancellation-policy check for an existing booking against :now.
Refund and fee semantics are consumer concerns layered on the result.
Expands a supported RFC 5545 RRULE subset into UTC intervals over a caller supplied horizon.
Computes the pure expiry transition for a consumer-supplied hold.
Normalizes iCalendar FREEBUSY periods into busy intervals.
Normalizes a decoded JSCalendar Event or Group into busy intervals.
Computes the pure no-show transition for an existing booking.
Like decide/5, but evaluates the reschedule policy against existing and
emits :booking_rescheduled semantics. The caller must remove only the
identified booking's own claims from resources; the kernel never subtracts
generic busy intervals by timestamp.
Checks a specific requested slot against availability and policy without committing to an assignment. Returns every failing reason, not just the first.
Functions
@spec assign([ExBooking.Resource.t()], ExBooking.Interval.t(), keyword()) :: {:ok, [ExBooking.Resource.t()]} | {:error, :no_eligible_resource | {:invalid, atom(), term()}}
Standalone assignment over pre-filtered free resources, for consumers that
run their own availability search. See ExBooking.Assignment.
Example
iex> slot =
...> ExBooking.Interval.new!(
...> ~U[2026-07-13 09:00:00Z],
...> ~U[2026-07-13 09:30:00Z]
...> )
...>
...> resources = [
...> %ExBooking.Resource{id: "b", timezone: "Etc/UTC"},
...> %ExBooking.Resource{id: "a", timezone: "Etc/UTC"}
...> ]
...>
...> {:ok, [winner]} = ExBooking.assign(resources, slot, [])
...> winner.id
"a"
@spec available_slots( ExBooking.MeetingType.t(), [ExBooking.Resource.t()], [ExBooking.AvailabilityRule.t()], keyword() ) :: {:ok, [ExBooking.Interval.t()]} | {:error, term()}
Runs availability search and returns bookable slots sorted ascending by start.
Options
:now(required) — the caller's current time:from,:until(required) — search horizon:align—:free_start(default) or:clock:strategy,:scorer— seeExBooking.Assignment
Example
iex> meeting_type = %ExBooking.MeetingType{id: "intro", duration_min: 30}
...>
...> ExBooking.available_slots(meeting_type, [], [],
...> now: ~U[2026-07-08 12:00:00Z],
...> from: ~U[2026-07-13 00:00:00Z],
...> until: ~U[2026-07-14 00:00:00Z]
...> )
{:ok, []}
@spec cancel(ExBooking.Interval.t(), ExBooking.MeetingType.t(), keyword()) :: {:ok, ExBooking.Decision.t()} | {:error, term()}
Computes the pure cancellation transition for an existing booking.
When cancellation policy allows the action, the returned decision emits
:booking_canceled, requests calendar cancellation, optionally releases an
existing hold, and leaves persistence/publishing to the consumer.
Example
iex> existing =
...> ExBooking.Interval.new!(
...> ~U[2026-07-13 09:00:00Z],
...> ~U[2026-07-13 09:30:00Z]
...> )
...>
...> meeting_type = %ExBooking.MeetingType{id: "intro", duration_min: 30}
...>
...> {:ok, decision} =
...> ExBooking.cancel(existing, meeting_type, now: ~U[2026-07-13 08:00:00Z])
...>
...> hd(decision.events).type
:booking_canceled
@spec decide( ExBooking.Request.t(), ExBooking.MeetingType.t(), [ExBooking.Resource.t()], [ExBooking.AvailabilityRule.t()], keyword() ) :: {:ok, ExBooking.Decision.t()} | {:error, term()}
The core entry point: validate, assign, and produce a full
ExBooking.Decision with events and side-effect intents.
A decision is returned even for rejections (status: :conflict or
:policy_reject); {:error, _} is reserved for malformed input.
Example
iex> slot =
...> ExBooking.Interval.new!(
...> ~U[2026-07-13 09:00:00Z],
...> ~U[2026-07-13 09:30:00Z]
...> )
...>
...> meeting_type = %ExBooking.MeetingType{id: "intro", duration_min: 30}
...>
...> request = %ExBooking.Request{
...> meeting_type_id: "intro",
...> invitee_timezone: "Etc/UTC",
...> slot: slot
...> }
...>
...> resource = %ExBooking.Resource{id: "resource_1", timezone: "Etc/UTC"}
...>
...> rule = %ExBooking.AvailabilityRule{
...> timezone: "Etc/UTC",
...> windows: [%{weekday: 1, start_time: ~T[09:00:00], end_time: ~T[10:00:00]}]
...> }
...>
...> {:ok, decision} =
...> ExBooking.decide(request, meeting_type, [resource], [rule],
...> now: ~U[2026-07-08 12:00:00Z]
...> )
...>
...> {decision.status, decision.resource_ids}
{:ok, ["resource_1"]}
@spec evaluate_cancellation( ExBooking.Interval.t(), ExBooking.MeetingType.t(), keyword() ) :: {:ok, %{allowed?: boolean(), reason: atom() | nil}} | {:error, term()}
Pure cancellation-policy check for an existing booking against :now.
Refund and fee semantics are consumer concerns layered on the result.
Example
iex> existing =
...> ExBooking.Interval.new!(
...> ~U[2026-07-13 09:00:00Z],
...> ~U[2026-07-13 09:30:00Z]
...> )
...>
...> meeting_type = %ExBooking.MeetingType{id: "intro", duration_min: 30}
...> ExBooking.evaluate_cancellation(existing, meeting_type, now: ~U[2026-07-13 08:00:00Z])
{:ok, %{allowed?: true, reason: nil}}
@spec expand_rrule( String.t() | ExBooking.RRule.t(), DateTime.t(), pos_integer(), keyword() ) :: {:ok, [ExBooking.Interval.t()]} | {:error, term()}
Expands a supported RFC 5545 RRULE subset into UTC intervals over a caller supplied horizon.
Supported rule parts are documented in ExBooking.RRule.
Example
iex> {:ok, [first]} =
...> ExBooking.expand_rrule(
...> "FREQ=DAILY;COUNT=1",
...> ~U[2026-07-13 09:00:00Z],
...> 30,
...> from: ~U[2026-07-13 00:00:00Z],
...> until: ~U[2026-07-14 00:00:00Z]
...> )
...>
...> first.start_at
~U[2026-07-13 09:00:00Z]
@spec expire_hold( ExBooking.Hold.t(), keyword() ) :: {:ok, ExBooking.Decision.t()} | {:error, term()}
Computes the pure expiry transition for a consumer-supplied hold.
Consumers decide when a hold is expired by comparing expires_at with their
own clock. This helper only returns the canonical event and release intent.
Example
iex> slot =
...> ExBooking.Interval.new!(
...> ~U[2026-07-13 09:00:00Z],
...> ~U[2026-07-13 09:30:00Z]
...> )
...>
...> hold = %ExBooking.Hold{
...> id: "hold_1",
...> slot: slot,
...> resource_ids: ["resource_1"],
...> meeting_type_id: "intro",
...> expires_at: ~U[2026-07-13 08:55:00Z]
...> }
...>
...> {:ok, decision} = ExBooking.expire_hold(hold, [])
...> [{:release, "hold_1"}, {:emit, event}] = decision.intents
...> event.type
:booking_expired
@spec import_ics_free_busy(String.t()) :: {:ok, [ExBooking.Interval.t()]} | {:error, term()}
Normalizes iCalendar FREEBUSY periods into busy intervals.
This is a pure parser over caller-supplied iCalendar text. It performs no file or network I/O.
Example
iex> {:ok, [busy]} =
...> ExBooking.import_ics_free_busy("FREEBUSY:20260713T090000Z/20260713T093000Z")
...>
...> busy.kind
:busy
@spec import_jscalendar_busy(map()) :: {:ok, [ExBooking.Interval.t()]} | {:error, term()}
Normalizes a decoded JSCalendar Event or Group into busy intervals.
This is a pure mapper over caller-supplied maps. JSON decoding and recurrence expansion remain consumer concerns.
Example
iex> {:ok, [busy]} =
...> ExBooking.import_jscalendar_busy(%{
...> "@type" => "Event",
...> "start" => "2026-07-13T09:00:00",
...> "timeZone" => "Etc/UTC",
...> "duration" => "PT30M"
...> })
...>
...> busy.kind
:busy
@spec mark_no_show(ExBooking.Interval.t(), ExBooking.MeetingType.t(), keyword()) :: {:ok, ExBooking.Decision.t()} | {:error, term()}
Computes the pure no-show transition for an existing booking.
No-show detection, fees, and notifications are consumer concerns. The kernel returns the canonical event so analytics and billing layers can consume a stable vocabulary.
Example
iex> existing =
...> ExBooking.Interval.new!(
...> ~U[2026-07-13 09:00:00Z],
...> ~U[2026-07-13 09:30:00Z]
...> )
...>
...> meeting_type = %ExBooking.MeetingType{id: "intro", duration_min: 30}
...> {:ok, decision} = ExBooking.mark_no_show(existing, meeting_type, [])
...> hd(decision.events).type
:booking_no_show
@spec reschedule( ExBooking.Interval.t(), ExBooking.Request.t(), ExBooking.MeetingType.t(), [ExBooking.Resource.t()], [ExBooking.AvailabilityRule.t()], keyword() ) :: {:ok, ExBooking.Decision.t()} | {:error, term()}
Like decide/5, but evaluates the reschedule policy against existing and
emits :booking_rescheduled semantics. The caller must remove only the
identified booking's own claims from resources; the kernel never subtracts
generic busy intervals by timestamp.
Example
iex> existing =
...> ExBooking.Interval.new!(
...> ~U[2026-07-13 08:00:00Z],
...> ~U[2026-07-13 08:30:00Z]
...> )
...>
...> requested =
...> ExBooking.Interval.new!(
...> ~U[2026-07-13 09:00:00Z],
...> ~U[2026-07-13 09:30:00Z]
...> )
...>
...> meeting_type = %ExBooking.MeetingType{id: "intro", duration_min: 30}
...>
...> request = %ExBooking.Request{
...> meeting_type_id: "intro",
...> invitee_timezone: "Etc/UTC",
...> slot: requested
...> }
...>
...> resource = %ExBooking.Resource{id: "resource_1", timezone: "Etc/UTC"}
...>
...> rule = %ExBooking.AvailabilityRule{
...> timezone: "Etc/UTC",
...> windows: [%{weekday: 1, start_time: ~T[09:00:00], end_time: ~T[10:00:00]}]
...> }
...>
...> {:ok, decision} =
...> ExBooking.reschedule(existing, request, meeting_type, [resource], [rule],
...> now: ~U[2026-07-08 12:00:00Z]
...> )
...>
...> hd(decision.events).type
:booking_rescheduled
@spec validate_request( ExBooking.Request.t(), ExBooking.MeetingType.t(), [ExBooking.Resource.t()], [ExBooking.AvailabilityRule.t()], keyword() ) :: :ok | {:error, [term()] | term()}
Checks a specific requested slot against availability and policy without committing to an assignment. Returns every failing reason, not just the first.
Example
iex> slot =
...> ExBooking.Interval.new!(
...> ~U[2026-07-13 09:00:00Z],
...> ~U[2026-07-13 09:30:00Z]
...> )
...>
...> meeting_type = %ExBooking.MeetingType{id: "intro", duration_min: 30}
...>
...> request = %ExBooking.Request{
...> meeting_type_id: "intro",
...> invitee_timezone: "Etc/UTC",
...> slot: slot
...> }
...>
...> resource = %ExBooking.Resource{id: "resource_1", timezone: "Etc/UTC"}
...>
...> rule = %ExBooking.AvailabilityRule{
...> timezone: "Etc/UTC",
...> windows: [%{weekday: 1, start_time: ~T[09:00:00], end_time: ~T[10:00:00]}]
...> }
...>
...> ExBooking.validate_request(request, meeting_type, [resource], [rule],
...> now: ~U[2026-07-08 12:00:00Z]
...> )
:ok