For the canonical meaning of active, trialing, paused, past_due, and
ended states — and for which lifecycle states grant access — see
Lifecycle Semantics. 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?/2call, two thin guards. The truth of who is entitled lives inlifecycle_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:
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:
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_ids 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.
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 activeprice_idthat is not in any plan'sprice_ids.:denyfails closed (the unmapped plan grants nothing);:raisesurfaces the drift loudly at check time. It never silently allows.past_due_grace:(default:none) — whether a:past_duesubscription keeps access during dunning.:nonefails closed immediately;:dunningreuses the dunning grace window; a positive integerNgrants an entitlement-specific N-day window measured frompast_due_sinceagainstAccrue.Clock. A grace grant is an affirmative, configured decision — see Lifecycle Semantics 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:
# 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
endThe macros expand to the explicit plug; reach for the plug form directly when you need to override the deny behavior or the billable resolver:
plug Accrue.Plug.RequireEntitlement,
feature: :reports,
on_deny: {:redirect, "/pricing"},
billable: &MyApp.billable_for/1Deny 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):
# 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
endA 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.
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
— 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:
:advisorydoes NOT changeentitled?/has_active_plan?. When sync is enabled, Accrue records eachentitlements.active_entitlement_summary.updatedwebhook 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 totrueis 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:
Set the config flag. Under
:entitlements, setstripe_native_sync: :advisory(the default is:disabled, which makes the entire path inert — the webhook reducer early-returns before any database read):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.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 theentitlements.active_entitlement_summary.updatedevent. 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 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_moreflag is persisted to a typed, indexedtruncatedcolumn, 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) 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:
%{feature: ..., result: true | false, resolver: ..., reason: ...,
surface: :plug | :live | nil, subject_type: ..., subject_id: ...}A few rules worth pinning:
subject_idis internal-only — the customer/billable id, never an email, name, or any PII.reasoncarries 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.surfaceis:plugor:livewhen the check came from a guard,nilfor a directAccrue.entitled?/2call.- Per-check decisions are telemetry-only — this path never writes to the
accrue_eventsaudit ledger. (Grant/revoke/sync lifecycle events are ledgered elsewhere; a per-request gate decision is not.)
Related guides
- Lifecycle Semantics — the SSOT for which lifecycle
states grant entitlement (the truth
entitling?/1encodes). - Telemetry — the
[:accrue, ...]span catalog and OTel wiring for the:checkevent above. - Auth adapters — how
Accrue.Authresolves 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.