Inspect an entitlement source
Use Accrue.Entitlements.Source.Registry, never Accrue.Processor, to inspect a rail's entitlement-source capability. The fixed order is observation, control, restore, reconciliation, management, and offline; the closed states are supported, externally_managed, host_owned, deferred, unavailable, and feasibility_blocked.
Apple subscription management is externally managed. Present the returned plain-language guidance and Manage subscription action, which links to https://apps.apple.com/account/subscriptions. Do not translate an Apple outcome into a Stripe cancellation, dunning, retry, swap, proration, invoice, or payment-method action. The repository's processor-support-matrix.md remains the separate processor authority and gateway-control authority.
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.
Additive rails and durable entitlement records
The existing :processor configuration is the supported legacy alias for the
single controllable billing rail. Hosts that need concurrent sources may opt in
to :rails and choose a controllable :default_rail; keep using price_ids
for the default rail/environment or use products for the explicit
rail/environment/product tuple. A product identifier is equal only with its
full qualified tuple — matching the same raw identifier on another rail or
environment is not a collision.
Apple is an entitlement source/observer. Apple does not implement Accrue.Processor; Stripe remains the controllable processor example. Phase 216 does not verify Apple signed material, and Phase 216 does not mutate the Apple subscription lifecycle. Hosts must retain Apple verification and lifecycle management at their own provider boundary.
Schedule Apple reconciliation
Accrue does not start an Oban instance, a Cron plugin, or a timer. A host that enables Apple reconciliation must add the queue and periodic worker to its existing Oban configuration, alongside the configured Apple client and strict admission settings:
config :my_app, Oban,
queues: [accrue_entitlements: 10],
plugins: [
{Oban.Plugins.Cron,
crontab: [{"*/15 * * * *", Accrue.Entitlements.Apple.ReconciliationSweeper}]}
]
config :accrue, :apple_reconciliation,
client: MyApp.AppleClient.new(...),
admission: [
verifier: MyApp.AppleVerifier,
verifier_config: [...],
product_map: %{"apple_product_id" => :pro},
verifier_version: "v1",
config_version: "v1"
]The Cron worker finds persisted idle checkpoints whose next_due_at is due,
locks each row, inserts a privacy-safe reconciliation job (lineage UUID,
environment, and bounded reason only), and marks the checkpoint running in the
same transaction. Apple status/history remains the authority; a scheduler tick
never grants access by itself. Oban uniqueness is not the execution lock.
To disable scheduled repair safely, first remove the Cron entry, then drain
queued reconciliation-worker jobs or atomically release
any scheduler-reserved running checkpoints back to idle. Do not delete the
checkpoint rows or add an Accrue supervision child.
The durable projection boundary has four Accrue-owned tables:
accrue_entitlement_accounts, accrue_entitlement_observations,
accrue_entitlement_grants, and accrue_entitlement_devices. They retain
opaque account links, normalized observations, current and superseded grant
history, and revoked device history. Evidence is limited to a digest plus an
optional bounded reference and expiry; do not persist raw receipts, signed
material, provider notification bodies, adopter identity, or PII in these
records.
API-only, read-only host setup
accrue_admin is not required to read entitlements. A Phoenix API application
can use the core context directly, and Phoenix itself remains optional for the
core package. After copying and running Accrue's migrations, the minimal
credential-free configuration is the host Repo plus its plan catalog; declaring
the Fake processor makes the no-provider-I/O posture explicit:
config :accrue,
repo: MyApp.Repo,
processor: Accrue.Processor.Fake,
entitlements: [
plans: [
pro: [features: [:reports, :api], limits: [seats: 5]]
]
]No Stripe secret, webhook secret, mailer, or email branding is required to boot
this read-only configuration. Read a normalized account with
Accrue.Entitlements.snapshot/2. The returned sources field is a sorted list
of provenance maps shaped as:
%{
rail: :stripe | :apple,
environment: :production | :sandbox,
logical_plan: :pro,
effective_at: ~U[2026-08-30 12:00:00Z],
expires_at: nil,
revoked_at: nil
}Use source.rail to select provider-owned billing controls. Google Play is not
a registered rail in this release.
Host tests can insert the four common render states without private dependency support files:
Accrue.Test.Entitlements.insert!(:none)
Accrue.Test.Entitlements.insert!(:stripe_active)
Accrue.Test.Entitlements.insert!(:stripe_cancelled_but_entitled)
Accrue.Test.Entitlements.insert!(:apple_active)Google Play, Family Sharing policy, and provider lifecycle mutation remain outside the current rail contract. Apple verification, App Store Server Notifications V2 intake, projection, and reconciliation are additive core surfaces; Apple subscription management itself remains external to Accrue.
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.
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
— 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.
Why there is no fetch_entitled/2
fetch_entitled/2 is closed and will-not-build. A Stripe-backed predicate would
make authorization depend on a network call that can fail open under partition,
which contradicts Accrue's fail-closed local gate. The non-gate diagnostic value
is already served by the internal summary_for_customer/1 seam and
Accrue.Entitlements.Admin.resolve_for_customer/1.
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 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_secrets), 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. For
missed webhooks or startup reconciliation, use the client-backed refresh path
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.
Client-backed refresh for missed webhooks and reconciliation
Accrue now ships the full client-backed read through
Accrue.Entitlements.StripeSync.refresh/2. When
stripe_native_sync: :advisory is enabled, the refresh asks the configured
processor for Stripe active entitlements and writes the same advisory cache row
as the webhook reducer. When the flag is disabled, it returns
{:ok, :disabled} before processor or repository I/O.
Use this path after missed webhook delivery, on operational startup reconciliation, or when an operator wants to compare Stripe's current native entitlement view with Accrue's local grant model:
customer = Accrue.Repo.get!(Accrue.Billing.Customer, customer_id)
Accrue.Entitlements.StripeSync.refresh(customer)Hosts that use Oban can enqueue the provided host-owned worker on the existing webhook queue:
%{"customer_id" => customer.id}
|> Accrue.Entitlements.StripeSync.RefreshWorker.new()
|> Oban.insert()Refresh errors return through the processor result and Oban retry semantics; a
successful refresh still writes diagnostics only. The local plan→feature map
remains the only Accrue grant authority, and refreshed advisory rows never
change entitled?/2, has_active_plan?/2, controller plugs, or LiveView
guards.
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
v1.59 multi-rail and offline adoption path
Use this path when a host needs the additive Stripe, Apple, and offline-study contract. It is deliberately short: the generated matrix owns exact support cells; this guide explains how to evaluate and operate the contract.
Start with the anonymized reference-host recipe in
examples/accrue_host/docs/adoption-proof-matrix.mdand run its localmix verifyproof.From the repository root, run the deterministic contract check:
cd accrue && mix accrue.entitlements.reference_scenarios --checkRead the generated
capability and limits matrixfor the exact supported, unsupported, privacy, and merge-authority cells.If a scenario does not converge, record its stable scenario ID and follow the matching procedure in Operator runbooks.
Evidence is deliberately split
deterministic_conformance is the merge-blocking semantic lane. It proves the
same account projection for Apple-to-web and Stripe-to-iOS scenarios without
claiming a mobile runtime. runtime_capability is a separate, non-blocking
lane; the checked-in Crosswake tracer is feasibility_blocked until it has the
required bridge compile/unit and physical-device evidence. Fake, browser,
simulator, and Swift-vector results do not change that status.
advisory_parity is non-blocking provider comparison evidence. Browser and
Playwright coverage is a complementary rendered-host check for accessible copy
and flows, not the semantic oracle for StoreKit, signed proof, offline cache
replacement, ordering, or key rotation.
Compatibility and privacy boundary
This is additive: legacy hosts remain compatible. Apple subscriptions stay externally managed, and Accrue does not transfer, merge, migrate, refund, or prorate lifecycle state across rails. When an offline lease is stale, a learner may continue downloaded study and local progress only; new value waits for a reconnect.
Keep raw transaction data, signed proof material, tokens, PII, provider payloads, and credential values out of diagnostics, telemetry, guides, and support tickets. Use the bounded diagnostic's state, reason, next action, age, and safe correlation instead.
- 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.