# `Agenda.Reconciliation`
[🔗](https://github.com/elixir-tempo/agenda/blob/v0.3.0/lib/agenda/reconciliation.ex#L1)

Whether a resource's claims account for the time it owed.

A schedule is the ledger read as *intent* — who should be where. A
timesheet is the same ledger read as *record* — who was. This module
compares the two readings over a period and reports the difference.

## Why a difference and not a sum

The obvious check is to total the claims and compare against a
number. That check passes on data that is wrong. A consultant who
misses a Tuesday and works the following Saturday totals exactly the
same as one who did neither, and a day billed against an office that
was shut totals the same as a day billed against one that was open. A
total that balances is not evidence the period is correct — it is
evidence that two errors were the same size.

So `unaccounted` and `overclaimed` are **interval sets, not
quantities**. They say *"Tuesday afternoon"*, which is something a
person can act on, where *"11.4 hours short"* is not.

## What counts as expected

Expected time is the resource's open hours inside the window, less
anything the caller says removes the obligation:

    Agenda.reconcile(ledger, dana, within: quarter, excluding: holidays)

`agenda` does not know what a public holiday is, and does not resolve
one. Holidays arrive as an ordinary `t:Tempo.t/0` interval set from
wherever the caller keeps them — a jurisdiction library, a CalDAV
feed through `Agenda.from_ical/1`, or a hand-written list.

The order matters and is deliberate. Holidays leave the expectation
*before* claims are compared against it, so **a holiday falling
inside a period of leave cannot consume that leave**: the day was
never owed, so no claim is needed to account for it. That is the
perennial payroll bug, and here it is unrepresentable rather than
guarded against.

## Leave is a claim, not a gap

Work and leave are both claims on the resource, distinguished by
`Agenda.Allocation`'s tag. Both account for expected time — a day of
annual leave is not missing time — and `by_tag` reports how the
period divided between them.

# `breach`

```elixir
@type breach() :: %{
  period: atom(),
  bucket: tuple() | :undated,
  breach: {:over, Agenda.Limit.measure()} | {:under, Agenda.Limit.measure()}
}
```

A limit that a period's claims did not satisfy.

`bucket` is the specific day, week or month at fault, as returned by
`Agenda.Limit.bucket/2`.

# `t`

```elixir
@type t() :: %Agenda.Reconciliation{
  breaches: [breach()],
  by_tag: %{optional(Agenda.Allocation.tag() | nil) =&gt; Tempo.t()},
  claimed: Tempo.t(),
  expected: Tempo.t(),
  overclaimed: Tempo.t(),
  resource: String.t(),
  unaccounted: Tempo.t(),
  within: Tempo.t()
}
```

What a resource owed over a window, and what it claimed.

# `balanced?`

```elixir
@spec balanced?(t()) :: boolean()
```

`true` when nothing is unaccounted, nothing is overclaimed, and no
limit was breached.

### Arguments

* `reconciliation` is a `t:t/0`.

### Returns

* `true` or `false`.

### Examples

    iex> import Tempo.Sigils
    iex> day = ~o"2026-06-16T09:00:00/2026-06-16T17:00:00"
    iex> {:ok, dana} = Agenda.open(Agenda.resource("Dana"), day)
    iex> arrangement = %Agenda.Arrangement{
    ...>   session: "Acme",
    ...>   interval: day,
    ...>   allocations: %{consultant: [dana]}
    ...> }
    iex> {:ok, ledger} = Agenda.allocate(Agenda.ledger(), arrangement, tag: {:project, "ACME"})
    iex> {:ok, report} = Agenda.reconcile(ledger, dana, within: ~o"2026-06-16/2026-06-16")
    iex> Agenda.Reconciliation.balanced?(report)
    true

# `explain`

```elixir
@spec explain(t()) :: [String.t()]
```

What did not add up, as sentences.

Eligibility in this library is always explained rather than merely
decided, and a reconciliation is the same: the point of the report is
the sentence a person is sent, not the boolean.

### Arguments

* `reconciliation` is a `t:t/0`.

### Returns

* a list of sentences, empty when the period balances.

### Examples

    iex> import Tempo.Sigils
    iex> {:ok, dana} =
    ...>   Agenda.open(Agenda.resource("Dana"), ~o"2026-06-16T09:00:00/2026-06-16T17:00:00")
    iex> arrangement = %Agenda.Arrangement{
    ...>   session: "Acme",
    ...>   interval: ~o"2026-06-16T09:00:00/2026-06-16T12:00:00",
    ...>   allocations: %{consultant: [dana]}
    ...> }
    iex> {:ok, ledger} = Agenda.allocate(Agenda.ledger(), arrangement, tag: {:project, "ACME"})
    iex> {:ok, report} = Agenda.reconcile(ledger, dana, within: ~o"2026-06-16/2026-06-16")
    iex> Agenda.Reconciliation.explain(report)
    ["Dana: 5 hours unaccounted — 2026Y6M16DT12H0M0S/T17H0M0S"]

# `reconcile`

```elixir
@spec reconcile(Agenda.Ledger.t(), Agenda.Resource.t(), keyword()) ::
  {:ok, t()} | {:error, term()}
```

Compare what `resource` claimed in the ledger against what it owed.

### Arguments

* `ledger` is a `t:Agenda.Ledger.t/0`.

* `resource` is a `t:Agenda.Resource.t/0`.

* `options` is a keyword list of options.

### Options

* `:within` is the window to reconcile — a Tempo value or an ISO 8601
  string. Required, because open hours may be an unbounded recurrence
  and have no materialisation without one.

* `:excluding` is time the resource did not owe — public holidays, a
  shutdown — as a `t:Tempo.t/0` interval set, any Tempo value that
  denotes a span (a bare day, `~o"2026-08-12"`, is the natural way to
  write a holiday), a string, or a list of any of those. Subtracted from the expectation before any claim is
  compared against it. The default is none.

* `:expected` states the owed time outright, as an
  `t:Tempo.t/0` interval set, instead of deriving it from the
  resource's open hours. `:excluding` still applies to it.

### Returns

* `{:ok, reconciliation}`, or

* `{:error, reason}` when the window or a set cannot be read.

### Examples

    iex> import Tempo.Sigils
    iex> {:ok, dana} =
    ...>   Agenda.open(Agenda.resource("Dana"), ~o"2026-06-16T09:00:00/2026-06-16T17:00:00")
    iex> arrangement = %Agenda.Arrangement{
    ...>   session: "Acme",
    ...>   interval: ~o"2026-06-16T09:00:00/2026-06-16T12:00:00",
    ...>   allocations: %{consultant: [dana]}
    ...> }
    iex> {:ok, ledger} = Agenda.allocate(Agenda.ledger(), arrangement, tag: {:project, "ACME"})
    iex> {:ok, report} = Agenda.reconcile(ledger, dana, within: ~o"2026-06-16/2026-06-16")
    iex> Tempo.IntervalSet.members(report.unaccounted)
    [~o"2026Y6M16DT12H0M0S/T17H0M0S"]

> *"Three of the eight hours are accounted for; the afternoon is not."*

---

*Consult [api-reference.md](api-reference.md) for complete listing*
