# Entitlements — gate features on what they paid for

For the canonical meaning of `active`, `trialing`, `paused`, `past_due`, and
ended states — and for *which* lifecycle states grant access — see
[Lifecycle Semantics](lifecycle_semantics.md). Use that guide for the truth of
"who is entitled"; use **this** guide for how to *ask* the question and how to
*enforce* the answer in a controller, a LiveView, and your config.

Accrue's entitlement layer (`Accrue.Entitlements`, surfaced on the `Accrue`
facade) answers one question — *"what has this billable paid for?"* — from
**local subscription state only**. It makes zero processor calls on the gate
path: it reads the same local rows your webhooks already keep in sync, so a gate
check is a database read at LiveView speed, never a blocking Stripe round-trip.

> **Tagline:** one config map, one `entitled?/2` call, two thin guards. The
> truth of *who* is entitled lives in `lifecycle_semantics.md`; this guide owns
> the *how*.

---

## Getting Started — the fail-closed easy path

The whole API collapses to one boolean. Ask whether the billable has a feature,
render accordingly:

```elixir
if Accrue.entitled?(user, :pro), do: render_pro(), else: upsell()
```

That is the contract you should internalize before anything else: **the only
path to `true` is an affirmative, resolved match.** Every ambiguity fails
closed. `nil`, a non-billable, a billable with no customer, no active
subscription, an *unmapped* active plan, and even a resolver that *raises* all
collapse to `false`. A billing or availability hiccup never hands out a paid
feature for free — there is no fail-open branch anywhere in the gate path.

The scalar variants follow the same rule:

```elixir
Accrue.has_active_plan?(user, :pro)         # holds the :pro plan? (atom or price_id string)
Accrue.features_for(user)                   # => [:pro, :reports, :api]  (sorted, deduped, UNION across all active subs)
Accrue.entitlement_quantity(user, :seats)   # => 5   (0 when unmapped/absent)
```

`has_active_plan?/2` and `features_for/1` answer over the **UNION of every
active subscription** the billable holds — a customer on two active plans
answers `true` for both, and their feature sets merge. There is no
"representative plan" footgun.

---

## Configure the catalog

Entitlements are **host-declared**: you map each logical plan to the features
and quotas it grants, and to the `price_id`s that count as "holding" it. This
lives under `:entitlements` in `config/runtime.exs` and is boot-validated — a
malformed catalog (or the same `price_id` mapped to two plans) raises
`Accrue.ConfigError` at boot, never silently at request time.

```elixir
config :accrue,
  entitlements: [
    plans: [
      pro: [
        features: [:reports, :api],
        limits: [seats: 5],
        price_ids: ["price_pro_monthly", "price_pro_yearly"]
      ],
      team: [
        features: [:reports, :api, :sso],
        limits: [seats: 25],
        price_ids: ["price_team_monthly"]
      ]
    ],
    unmapped_action: :deny,
    past_due_grace: :none
  ]
```

Two knobs decide the fail-closed posture:

- **`unmapped_action:`** (default `:deny`) — what happens when a billable holds
  an *active* `price_id` that is not in any plan's `price_ids`. `:deny` fails
  closed (the unmapped plan grants nothing); `:raise` surfaces the drift loudly
  at check time. It never silently allows.
- **`past_due_grace:`** (default `:none`) — whether a `:past_due` subscription
  keeps access during dunning. `:none` fails closed immediately; `:dunning`
  reuses the dunning grace window; a positive integer `N` grants an
  entitlement-specific N-day window measured from `past_due_since` against
  `Accrue.Clock`. A grace grant is an affirmative, *configured* decision — see
  [Lifecycle Semantics](lifecycle_semantics.md#lifecycle--entitlement-truth-table)
  for the full grace-window nuance, which is the SSOT.

By default the resolver is `Accrue.Entitlements.Resolver.LocalMap`; swap it via
`resolver:` if you implement the `Accrue.Entitlements.Resolver` behaviour.

---

## Gate a controller route

For controller-level gating Accrue ships a pure Plug,
`Accrue.Plug.RequireEntitlement`, plus two router macros that are single-arg
sugar over it. Add the macros to a pipeline (or pipe-through) so a whole scope
is gated:

```elixir
# lib/my_app_web/router.ex
import Accrue.Router   # brings require_feature/1 and require_plan/1 into scope

pipeline :require_reports do
  plug :fetch_current_user            # YOUR auth runs first — it resolves the billable
  require_feature :reports            # plug Accrue.Plug.RequireEntitlement, feature: :reports
end

pipeline :require_pro do
  plug :fetch_current_user
  require_plan :pro                   # plug Accrue.Plug.RequireEntitlement, plan: :pro
end

scope "/app", MyAppWeb do
  pipe_through [:browser, :require_reports]
  live "/reports", ReportsLive
end
```

The macros expand to the explicit plug; reach for the plug form directly when
you need to override the deny behavior or the billable resolver:

```elixir
plug Accrue.Plug.RequireEntitlement,
  feature: :reports,
  on_deny: {:redirect, "/pricing"},
  billable: &MyApp.billable_for/1
```

**Deny is opaque by default.** A denied request gets a content-negotiated `403
Forbidden` whose body leaks nothing — no feature name, no plan, no subscription
state. That is deliberate: a gate should not advertise what the caller is
missing. Override per-guard via `on_deny:` (`:forbidden | {:redirect, path} |
{status, body} | fun/2 | {m, f, a}`), or globally via the `:on_deny` config key;
the precedence is per-guard opt → config global → built-in opaque 403. The
billable is resolved once per request (your `billable:` fn, else the global
`:billable` config, else a `current_scope.user → current_user → nil` probe) and
the resolver **never raises** — a miss resolves to `nil`, which fails closed.

---

## Gate a LiveView

For route-level gating of host LiveViews, the `on_mount` guard
`Accrue.Live.Entitlements` mounts the same decision engine. It is
**conditionally compiled** — it only exists when `Phoenix.LiveView` is loaded,
so core stays runtime-LiveView-free. Add it to a `live_session`, **after** your
own auth `on_mount` hook (which resolves the billable):

```elixir
# lib/my_app_web/router.ex
live_session :paid,
  on_mount: [
    MyAppWeb.UserAuth,                                   # YOUR auth FIRST
    {Accrue.Live.Entitlements, {:require_feature, :reports}}
  ] do
  live "/reports", ReportsLive
end

# Or gate on holding a whole plan:
live_session :pro,
  on_mount: [
    MyAppWeb.UserAuth,
    {Accrue.Live.Entitlements, {:require_plan, :pro}}
  ] do
  live "/admin", AdminLive
end
```

A denied mount is content-negotiated the LiveView way: a `{:redirect, path}`
deny redirects there; an opaque `:forbidden` (or non-redirectable status/body)
degrades to a flash plus a redirect to the configured `deny_path` (default
`"/"`). The billable is resolved once per mount and stashed via `assign_new`, so
nested live navigations don't re-probe. As with the plug, the only path to a
granted mount is an affirmative resolved match.

For organization-scoped gates, make your host auth/scope loader populate the
organization billable before `Accrue.Live.Entitlements` runs. If the resolver
returns `%Ecto.Association.NotLoaded{}` or another unloaded billable, Accrue
normalizes it to a fail-closed deny instead of raising; preload the organization
in your auth hook when the route should be grantable.

---

## Lifecycle truth

Entitlement is derived from `Accrue.Billing.Subscription.entitling?/1`, which
composes the lifecycle predicates (`active?/1`, `paused?/1`, `canceled?/1`) —
never raw `.status`. The reader-critical rows:

| Status / modifier | Entitled? | Basis |
|---|:---:|---|
| `:trialing` | ✅ | `active?` includes trialing |
| `:active` | ✅ | normal paid-active |
| `:active` + `cancel_at_period_end` (period future) | ✅ | paid-through |
| `:active` + `pause_collection` non-nil | ✗ | `paused?` overrides status |
| `:past_due` | ✗ default / ✅ in-grace | knob (`past_due_grace`) |
| `:canceled` / `:incomplete_expired` / any `ended_at` | ✗ | `canceled?` terminal |

This is a *summary*. The grace footnote nuance (`past_due_since`,
`Accrue.Clock`, the `:past_due_grace`/`:past_due_expired` reasons, and why
`:unpaid` never receives grace) lives in the SSOT, not here.

Canonical source:
[`lifecycle_semantics.md#lifecycle--entitlement-truth-table`](lifecycle_semantics.md#lifecycle--entitlement-truth-table)
— `entitling?/1` is the single source of truth, and every surface (this guide,
the resolver, the admin view) derives from it rather than re-deriving from
`.status`.

---

## Provider honesty

Entitlement resolution is **local-identical across Stripe, Braintree, and
Fake** — it reads local subscription state, never the processor. There is no
"Stripe-only" or "bounded on Braintree" caveat here: because the gate derives
from the local mirror your webhooks already maintain, the exact same
`Accrue.entitled?/2` call returns the byte-identical answer on every provider,
and the deterministic Fake lane is a first-class merge-blocking proof of that
convergence — not a degraded stand-in.

For the machine-readable capability surface, see `Accrue.Processor.Capabilities`
(the `entitlements:` capability group is labeled "all first-party" — the
matrix's one *convergence* lane). The optional Stripe-native entitlement sync is
a separate, off-by-default overlay; the core gate described here needs no Stripe
dependency. That overlay is the `entitlements.stripe_native_sync` capability row
(labeled **"Stripe-native advisory (observational)"**, with Stripe `native
(advisory)`, Fake out-of-slice, and Braintree unsupported) — distinct from the
`entitlements.local_mapping` convergence row above.

---

## Optional Stripe-native sync (advisory)

Everything above is **local-first and Stripe-free**. Accrue also ships an
**optional, off-by-default** path that ingests Stripe's native entitlement
summaries into a local *advisory cache*. It is **observational only** — turning
it on never changes a gate decision. This section is the operator's guide to
what it does, how to enable it, and the consistency caveats you inherit when you
do.

### What `:advisory` means — observational, not gate-influencing

> **The disclaimer, plainly: `:advisory` does NOT change `entitled?` /
> `has_active_plan?`.** When sync is enabled, Accrue records each
> `entitlements.active_entitlement_summary.updated` webhook into an advisory
> cache for **audit, telemetry, and the admin read-seam** — and nothing else.
> Local plan→feature mapping stays **canonical** in v1.x; the gate path never
> reads the cache. `entitled?` behaves byte-for-byte the same with sync ON, OFF,
> or as it did after Phase 126. The sole path to `true` is still an affirmative,
> resolved **local** match.

This is deliberate. An eventual-consistency Stripe cache that silently fed gate
decisions would be an authorization surface: a stale or partial snapshot could
hand out — or withhold — a paid feature. Keeping the overlay observational means
a cache that is stale, partial, or missing entirely **can never produce a wrong
gate answer**. (Gate-influencing semantics are reserved as a future,
non-breaking opt-in enum value — see *Deferred* below — but they are not v1.x.)

The advisory cache is exposed read-only via a core seam — the
`Accrue.Entitlements.StripeSync` module's `summary_for_customer/1` function
(one-way, internal `@doc false`) — so the recorded summary is programmatically
inspectable without ever touching the gate.

### How to enable it

Enabling is a **two-step opt-in** — both are required:

1. **Set the config flag.** Under `:entitlements`, set `stripe_native_sync:
   :advisory` (the default is `:disabled`, which makes the entire path inert —
   the webhook reducer early-returns before any database read):

   ```elixir
   config :accrue,
     entitlements: [
       plans: [ # ... your catalog, as above ... ],
       unmapped_action: :deny,
       past_due_grace: :none,
       stripe_native_sync: :advisory   # default :disabled
     ]
   ```

   The key is a boot-validated enum (`:disabled | :advisory`), not a boolean, so
   future modes (e.g. the deferred paginated reconcile) can be appended without a
   breaking config change.

2. **Enable the Stripe event on your Dashboard.** This is **host-owned** — Accrue
   cannot do it for you. On your Stripe webhook endpoint (the same one Accrue
   already verifies under your `:webhook_signing_secret`), enable the
   `entitlements.active_entitlement_summary.updated` event. Until that event is
   enabled in Stripe, no summaries arrive and the cache stays empty.

With both in place, each summary webhook is reduced into the advisory cache with
the same **monotonic skip-stale** discipline the rest of Accrue uses (older
out-of-order or replayed summaries are skipped, never clobbering newer state),
and a `entitlements.summary.synced` ledger row is recorded on each *material*
change. See [Telemetry](telemetry.md) for the full event catalog.

### The eventual-consistency window

Stripe webhooks carry **no delivery-order guarantee and no documented
propagation-lag SLA** — a summary can lag the underlying change, arrive
out-of-order, or fail delivery and retry. The advisory cache is therefore
**eventually consistent**: it can briefly trail Stripe's actual state.

This is harmless *because the cache is observational*. Local-first canonical
resolution means a stale advisory cache **never produces a wrong gate
decision** — the local subscription projection (kept in sync by
`customer.subscription.*` webhooks on the same monotonic discipline) is the
truth the gate reads. The monotonic guard guarantees the cache, once it catches
up, reflects the highest-timestamp summary regardless of delivery order. The
proper fix for *missed* webhooks (the deferred reconcile) is described below.

### The 10-entitlement inline cap

The summary webhook inlines **at most 10** entitlements in
`entitlements.data`, with `has_more: true` and a `url` pagination handle when a
customer holds more. Accrue records exactly what the webhook delivers and is
**honest about partiality**:

- The `has_more` flag is persisted to a typed, indexed `truncated` column, so a
  known-incomplete cache row is queryable and operator-visible.
- When `has_more: true`, Accrue fires the ops signal
  `[:accrue, :ops, :entitlement_summary_truncated]` (see
  [Telemetry](telemetry.md)) so operators can find partial caches without
  scanning.
- Because the cache is observational, a truncated (partial) summary can **never**
  cause a wrong gate decision — it is surfaced for transparency, not consulted
  for access.

### Deferred: the full paginated read (`lattice_stripe >= 1.2`)

The complete fix for both missed webhooks (eventual consistency) and the
10-entitlement cap is a **full paginated read** of Stripe's
`GET /v1/entitlements/active_entitlements` API — fetched on startup and to
reconcile after a webhook delivery failure, following Stripe's own guidance.
That read is **deferred**: `lattice_stripe 1.1` has no Entitlements list API, so
the monotonic-snapshot reducer is the complete in-scope path for v1.x. The
paginated reconcile lands as a follow-up depending on `lattice_stripe >= 1.2`
(exposed as a future `stripe_native_sync` mode value). Until then, `truncated`
and the truncation ops event surface the gap honestly.

---

## Telemetry

Every check emits `[:accrue, :entitlements, :check]` start/stop/exception
spans via `Accrue.Telemetry.span/3`, with metadata:

```elixir
%{feature: ..., result: true | false, resolver: ..., reason: ...,
  surface: :plug | :live | nil, subject_type: ..., subject_id: ...}
```

A few rules worth pinning:

- **`subject_id` is internal-only** — the customer/billable id, *never* an
  email, name, or any PII.
- **`reason`** carries the *why* of a deny (e.g. `:no_active_subscription`,
  `:not_entitled`, `:past_due_grace`, `:past_due_expired`) so "denied" and
  "couldn't check" are distinguishable in telemetry without leaking through the
  opaque 403.
- **`surface`** is `:plug` or `:live` when the check came from a guard, `nil`
  for a direct `Accrue.entitled?/2` call.
- **Per-check decisions are telemetry-only** — this path *never* writes to the
  `accrue_events` audit ledger. (Grant/revoke/sync lifecycle events are ledgered
  elsewhere; a per-request gate decision is not.)

---

## Related guides

- [Lifecycle Semantics](lifecycle_semantics.md) — the SSOT for which lifecycle
  states grant entitlement (the truth `entitling?/1` encodes).
- [Telemetry](telemetry.md) — the `[:accrue, ...]` span catalog and OTel wiring
  for the `:check` event above.
- [Auth adapters](auth_adapters.md) — how `Accrue.Auth` resolves the host
  identity that the guards turn into a billable.
- **Admin entitlements view** — in `accrue_admin`, a customer's resolved active
  plans, granted features, quantities, grace state, and unmapped-plan drift are
  visible at `/customers/:id?tab=entitlements`.
