Entitlements (the `entitled?` API)
Reference for the four-method entitlement surface on the User model — `entitled?`, `subscribed?`, `plan`, and `limit` — plus usage recording, the plan / entitlement / plan_entitlement / usage_limit data model, and controller and view gating examples.
supabase-billing exposes a tiny, opinionated entitlement API on your User model. Once include Acts::Billable is in app/models/user.rb (the install generator inserts it automatically — see Getting Started), every controller, view, mailer, and background job that already has access to a User can ask the four questions that matter:
current_user.subscribed? # => true / false
current_user.plan # => #<Billing::Plan key: "pro"> | nil
current_user.entitled?(:projects) # => true / false
current_user.limit(:projects) # => 100 | nilEverything else on this page is a refinement of those four methods — what they read, how they cache, the data model they sit on top of, and the patterns for gating controllers, views, and metered usage.
The four core methods
All four methods are added to your User model by include Acts::Billable. They read from the canonical Billing::* ActiveRecord models — never a provider SDK — and return safe defaults when the user has no billing_customer or no active subscription.
user.subscribed?
Returns true when the user has a Billing::Subscription whose status is "active" or "trialing", and false otherwise.
current_user.subscribed? # => truesubscribed? does not consider entitlements — it answers the broad question "is this user a paying customer right now?". Use it for top-level gates (e.g. "logged-in dashboard vs. upgrade page") and reach for entitled? for per-feature checks.
user.plan
Returns the Billing::Plan ActiveRecord row associated with the user's active subscription, or nil if they have no active subscription:
current_user.plan # => #<Billing::Plan key: "pro", name: "Pro">
current_user.plan&.key # => "pro"
current_user.plan&.name # => "Pro"The returned Billing::Plan is the canonical plan record — the same one keyed by the Symbol you declared in config.plan :pro in the plan DSL. Use plan&.key (the string key, e.g. "pro") for comparisons and display labels.
user.entitled?(:key)
The headline method. Returns true when the user's active subscription's plan grants the named entitlement and the entitlement's quota (if numeric) is positive; false otherwise.
current_user.entitled?(:sso) # => true (boolean entitlement granted)
current_user.entitled?(:ai_requests) # => true (numeric entitlement, value > 0)
current_user.entitled?(:not_in_plan) # => false (entitlement key not granted)Resolution rules, in order:
- If the user has no active subscription, return
false. - If the active plan has no
plan_entitlementlink for the key, returnfalse. - If the link's
value_booleanis set (true or false), return that. - If the link's
value_numericis set, returnvalue_numeric > 0. - Otherwise, return
false.
The argument can be a Symbol or String; both are coerced to a string and matched against entitlements.key.
user.limit(:key)
Returns the numeric cap for a metered entitlement, or nil when the entitlement is boolean, the plan doesn't grant it, or the user has no active subscription.
current_user.limit(:ai_requests) # => 10_000
current_user.limit(:projects) # => 100
current_user.limit(:sso) # => nil (boolean entitlement)
current_user.limit(:not_in_plan) # => nillimit reads plan_entitlements.value_numeric for the active plan and the requested key — it does not consider how much of the limit the user has already consumed. For "how much is left," see usage and remaining below.
Entitlement checks are request-scoped cached
All four methods (plus usage and remaining) cache their results per request in Supabase::Billing::Current.entitlement_cache. Rails clears this cache automatically at the request boundary, so a single controller action that calls entitled?(:foo) three times only hits the database once. Disable globally via Supabase::Billing.config.cache_entitlements_per_request = false for apps that need always-fresh reads.
The data model: plans, entitlements, plan_entitlements, usage_limits
The four entitled? methods read from four canonical tables. Knowing how they fit together makes it obvious where to add a new entitlement, why a key doesn't resolve, or what to query directly for an admin dashboard.
plans— one row per pricing tier (free,pro,enterprise). Keyed by theSymbolyou declared inconfig.plan :pro. Plans removed from the DSL getarchived_atstamped rather than deleted, so historic subscriptions stay resolvable.entitlements— one row per feature key (:ai_requests,:projects,:sso). Each entitlement has akindof"numeric"or"boolean"that's inferred from the value type the first time it appears in aconfig.planblock.plan_entitlements— the join table that says "plan X grants entitlement Y at value Z." Each row has eithervalue_numeric(for metered limits, e.g.10_000) orvalue_boolean(for on/off features, e.g.true).entitled?(:key)consults this table for the user's active plan;limit(:key)returns thevalue_numeric.usage_limits— per-subscription per-period quotas that can override the plan-level cap. Optional. Each row points at asubscriptionand anentitlement, with avalue_numericand aperiod_start/period_end. Use this when one customer needs a non-standard cap without forking the plan.
The Billing::ApplicationRecord ActiveRecord classes mirror these tables one-for-one and ship with the standard associations:
Billing::Plan
has_many :plan_entitlements
has_many :entitlements, through: :plan_entitlements
has_many :subscriptions
has_many :provider_products
Billing::Entitlement
has_many :plan_entitlements
has_many :plans, through: :plan_entitlements
has_many :usage_limits
has_many :usage_events
Billing::PlanEntitlement
belongs_to :plan
belongs_to :entitlement
Billing::UsageLimit
belongs_to :subscription
belongs_to :entitlementIn normal app code you should never need to touch these directly — entitled?, limit, and record_usage cover the full surface. Reach for them when you're building admin pages or migrating data. See Schema Reference for the full column-level breakdown of every table.
Editing the plan DSL
The source of truth for which plan grants which entitlement is the plan DSL in config/initializers/supabase_billing.rb:
SupabaseBilling.configure do |config|
config.plan :free, name: "Free" do
entitlement :ai_requests, value: 100
entitlement :projects, value: 1
entitlement :sso, value: false
end
config.plan :pro, name: "Pro" do
entitlement :ai_requests, value: 10_000
entitlement :projects, value: 100
entitlement :sso, value: false
end
config.plan :enterprise, name: "Enterprise" do
entitlement :ai_requests, value: 1_000_000
entitlement :projects, value: 10_000
entitlement :sso, value: true
end
endAfter any change to the DSL, reconcile it into the database:
bin/rails supabase_billing:syncsync upserts the plans, entitlements, and plan_entitlements rows so they match the DSL. Numeric values populate plan_entitlements.value_numeric; true / false values populate plan_entitlements.value_boolean. The same key reused across plans (e.g. :ai_requests) is a single entitlements row with one plan_entitlement per plan.
Gating a controller action
The most common use case: refuse to execute an action unless the user has the entitlement.
class Api::AiRequestsController < ApplicationController
before_action :require_authentication
before_action :require_ai_quota, only: :create
def create
response = AiClient.complete(params[:prompt])
Current.user.record_usage(:ai_requests, amount: 1)
render json: response
end
private
def require_ai_quota
return if Current.user.entitled?(:ai_requests)
render json: {
error: "quota exceeded",
plan: Current.user.plan&.key,
limit: Current.user.limit(:ai_requests)
}, status: :payment_required
end
endFor "any paying user can use this," gate on subscribed? instead:
class ExportsController < ApplicationController
before_action :require_authentication
before_action :require_subscription
def create
Export.create!(user: Current.user, format: params[:format])
end
private
def require_subscription
return if Current.user.subscribed?
redirect_to billing_url, alert: "An active subscription is required."
end
endCurrent.user and current_user are interchangeable here — both come from supabase-rails and both have Acts::Billable mixed in.
Gating UI in views
The same methods work in views and helpers. They share the per-request cache, so calling entitled? once in a controller and again in the view rendered for that request is a single database read.
<%# app/views/dashboard/index.html.erb %>
<% if Current.user.entitled?(:sso) %>
<%= link_to "SSO settings", sso_settings_path, class: "btn" %>
<% else %>
<div class="upgrade-prompt">
SSO is a Pro feature. <%= link_to "Upgrade", billing_path %>.
</div>
<% end %>
<% if Current.user.entitled?(:ai_requests) %>
<p>
You have used <%= Current.user.usage(:ai_requests) %> of
<%= Current.user.limit(:ai_requests) %> AI requests this period.
<%= Current.user.remaining(:ai_requests) %> remaining.
</p>
<% else %>
<p>Your AI quota for this period is exhausted.</p>
<% end %>A small helper keeps view code tidy when the same entitlement is gated in several places:
# app/helpers/billing_helper.rb
module BillingHelper
def gated(entitlement_key, &block)
return capture(&block) if Current.user&.entitled?(entitlement_key)
render(partial: "shared/upgrade_prompt", locals: { entitlement: entitlement_key })
end
end<%= gated(:sso) do %>
<%= link_to "SSO settings", sso_settings_path %>
<% end %>Metered usage: usage, remaining, and record_usage
For numeric entitlements you want to consume against (API calls, exports, AI tokens), Acts::Billable adds three more methods on top of the core four:
current_user.usage(:ai_requests) # => 248 (sum of recorded events this period)
current_user.limit(:ai_requests) # => 10_000
current_user.remaining(:ai_requests) # => 9_752
current_user.record_usage(:ai_requests, amount: 1)usage(:key)— sumsusage_events.amountfor the user'sbilling_customerand the named entitlement, scoped to the active subscription'scurrent_period_start...current_period_end. Returns0when the user has no customer record, no active subscription, or the entitlement key isn't registered.remaining(:key)— convenience forlimit(:key) - usage(:key). Returnsnilwhen the entitlement has no numeric cap (e.g. it's boolean, unlimited, or the user has no subscription).record_usage(:key, amount: 1, recorded_at: nil)— writes aBilling::UsageEventrow tied to the user'sbilling_customerand the entitlement, and invalidates the per-request cache for that key so a subsequentusage/remaining/entitled?re-reads from the database.recorded_atdefaults toTime.now.utc.
The usage_events table is also a direct ActiveRecord model — Billing::UsageEvent — if you need to query history outside the current period or aggregate by day:
Billing::UsageEvent
.where(billing_customer: Current.user.billing_customer)
.where(entitlement: Billing::Entitlement.find_by!(key: "ai_requests"))
.where(recorded_at: 30.days.ago..)
.sum(:amount)record_usage raises Supabase::Billing::Error when the user has no billing_customer record or when the entitlement key isn't registered in the DSL — both are programmer-error conditions that should fail loudly in development, not be silently ignored.
Numeric entitlements are quotas, not credits
entitled?(:ai_requests) returns true while plan_entitlements.value_numeric is positive — it does not auto-decrement as you call record_usage. The "have they exhausted their quota?" check belongs in your app code (or in a usage_limits override row), typically as:
Current.user.entitled?(:ai_requests) && Current.user.remaining(:ai_requests).to_i.positive?If you need hard period-scoped caps enforced at the DB layer, populate usage_limits rows during webhook ingestion and consult them in your gating method.
Cache invalidation
The per-request cache is invalidated automatically when:
- Rails finishes the request (the
Supabase::Billing::CurrentCurrentAttributessubclass is reset by the executor). record_usage(:key)writes aUsageEvent— it callsacts_billable_invalidate_cache(:key)so subsequent reads in the same request see the new total.
If you write to billing tables out-of-band (e.g. an admin tool inside the same request that creates a subscription manually), call the public invalidator yourself:
Current.user.acts_billable_invalidate_cache # drops all cached entitlements for this user
Current.user.acts_billable_invalidate_cache(:projects) # drops just :projects (entitled?, limit, usage, remaining)For development hosts that want to disable caching entirely, set Supabase::Billing.config.cache_entitlements_per_request = false in config/initializers/supabase_billing.rb.
Entitlement checks are RLS-aware
The canonical billing_customers, subscriptions, and subscription_items tables ship with *_select_own Row-Level Security policies scoped to auth.uid() (installed by the supabase_billing:install migration). Because entitled?, subscribed?, plan, and limit resolve through ActiveRecord against the request's Supabase JWT context — set up by supabase-rails — a misconfigured controller cannot leak another user's subscription state through these methods. The same RLS scoping also applies when the Supabase JS or iOS SDKs read these tables directly with the anon key. Pass --skip-rls to the install generator if you intend to author the policies by hand, but never run multi-tenant production traffic without RLS in place.
Next steps
Schema Reference
Per-table column reference and ER diagram for every canonical and provider-mapping table the entitlement methods read.
Providers
How Stripe and Adapty webhooks populate billing_customers, subscriptions, and plan_entitlements in the first place.
Webhooks
Endpoint contracts, signature verification, and the provider_events idempotency model.
Getting Started
From bundle add to a gated controller action — the install path that sets entitled? up in the first place.
Providers (Stripe + Adapty)
Side-by-side Stripe (web) and Adapty (mobile) provider setup — webhook endpoints, signing secret env vars, ingestion modes, supported event types, and the `config/initializers/supabase_billing.rb` block for each.
Schema Reference
The twelve tables created by `rails g supabase_billing:install` — eight canonical tables for the entitlement engine plus four provider-mapping tables — with an ER diagram and the RLS model.