supabase-rb-rb

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 | nil

Everything 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?  # => true

subscribed? 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:

  1. If the user has no active subscription, return false.
  2. If the active plan has no plan_entitlement link for the key, return false.
  3. If the link's value_boolean is set (true or false), return that.
  4. If the link's value_numeric is set, return value_numeric > 0.
  5. 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)  # => nil

limit 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 the Symbol you declared in config.plan :pro. Plans removed from the DSL get archived_at stamped rather than deleted, so historic subscriptions stay resolvable.
  • entitlements — one row per feature key (:ai_requests, :projects, :sso). Each entitlement has a kind of "numeric" or "boolean" that's inferred from the value type the first time it appears in a config.plan block.
  • plan_entitlements — the join table that says "plan X grants entitlement Y at value Z." Each row has either value_numeric (for metered limits, e.g. 10_000) or value_boolean (for on/off features, e.g. true). entitled?(:key) consults this table for the user's active plan; limit(:key) returns the value_numeric.
  • usage_limits — per-subscription per-period quotas that can override the plan-level cap. Optional. Each row points at a subscription and an entitlement, with a value_numeric and a period_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 :entitlement

In 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
end

After any change to the DSL, reconcile it into the database:

bin/rails supabase_billing:sync

sync 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
end

For "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
end

Current.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) — sums usage_events.amount for the user's billing_customer and the named entitlement, scoped to the active subscription's current_period_start ... current_period_end. Returns 0 when the user has no customer record, no active subscription, or the entitlement key isn't registered.
  • remaining(:key) — convenience for limit(:key) - usage(:key). Returns nil when 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 a Billing::UsageEvent row tied to the user's billing_customer and the entitlement, and invalidates the per-request cache for that key so a subsequent usage / remaining / entitled? re-reads from the database. recorded_at defaults to Time.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::Current CurrentAttributes subclass is reset by the executor).
  • record_usage(:key) writes a UsageEvent — it calls acts_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

On this page