ExBooking (ExBooking v0.1.0)

View Source

Pure 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

assign(resources, slot, opts)

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

available_slots(meeting_type, resources, rules, opts)

@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 — see ExBooking.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, []}

cancel(existing, meeting_type, opts)

@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

decide(request, meeting_type, resources, rules, opts)

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

evaluate_cancellation(existing, meeting_type, opts)

@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}}

expand_rrule(rrule, dtstart, duration_min, opts)

@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]

expire_hold(hold, opts)

@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

import_ics_free_busy(ics)

@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

import_jscalendar_busy(object)

@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

mark_no_show(existing, meeting_type, opts)

@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

reschedule(existing, request, meeting_type, resources, rules, opts)

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

validate_request(request, meeting_type, resources, rules, opts)

@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