Feature passes: bounded, create-only samples on top of feature grants.
Expiring per-owner grants have existed since 0.6.0 (grant_feature! with expires_at:). 0.7.0 adds what a sales or support offer needs on top of them:
issue_feature_pass!is create-only: it raisesFeatureGrantConflictinstead of overwriting an existing active grant, so an operator's "issue" button can never shorten a permanent promise.grant_feature!keeps its upsert semantics and now preserves a pass's limits and consumption when they are omitted- A pass can carry named capacity
limits:(a hash the app measures against at its write boundary) and a cumulativeusage_limit:whoseusage_countthe gem reserves atomically. Both are properties of the pass alone: plan and grandfather access carry no named limits, and the app keeps owning its plan quotas exactly as before feature_access(:feature)returns a read-onlyFeatureAccesssnapshot for UI and preflight:source,grant,expires_at,limit(:key),usage_limit,usage_count,remaining_allowance,available?,check!with_feature_access!(:feature, amount:, usage:)locks a fresh copy of the owner row, resolves access again, checks the pass limits and cumulative allowance, reservesamount, and yields inside a savepoint; an exception escaping the block rolls back the reservation together with the business write.FeatureLimitExceeded < FeatureDeniedcarrieslimit_key,allowed, andrequestedFeatureGrant#revise!changesexpires_at,limits,usage_limit, ornoteon an active row while preserving consumption, and refuses expired or revoked rows;revoke!is serialized with consumption- Three additive columns on
pricing_plans_feature_grants(limits,usage_limit,usage_count) plus a nonnegative check constraint. Fresh installs andpricing_plans:grantsinclude them; apps already on the 0.6.x table runrails generate pricing_plans:passes && rails db:migrate. Old-schema boolean grants keep working, and bounded writes raise aConfigurationErrornaming the generator when the columns are missing - Full guide:
docs/08-feature-passes.md - The plan-assignment APIs deprecated in 0.5.0 (
assign_pricing_plan!,remove_pricing_plan!,Assignment.assign_plan_to,Assignment.remove_assignment_for) stay for one more minor: the deprecation horizon moves to 0.8.0 so this feature release carries no removals. They go in 0.8.0 together with the first-class plan-change hook (#13) and plan groups (#22)
Repricing without app migrations: grandfathering and per-owner feature grants.
- Add the
grandfatherplan DSL:grandfather :feature, subscribed_before: <time>declares that owners whose qualifying pricing relationship predates the cutoff keep a feature the plan no longerallows. Pure configuration — no columns, no backfills, no rake tasks; the initializer stays the git-versioned record of every pricing change - Grandfather eligibility uses the older of the current assignment and same-plan subscription
created_at. It models a continuous pricing relationship (Pay keeps the same subscription row across price swaps, and updating an assignment keeps that row's age), not unavailable plan-change history; lapse/re-subscribe or clear/reassign resets the corresponding timestamp, while exact materialized cohorts belong in feature grants - Add
PricingPlans::FeatureGrantand the per-owner grants API:grant_feature!,revoke_feature!,feature_granted?,feature_grants— individual, auditable exceptions (comps, beta access, sales promises, remediation) that attach to the owner and survive plan changes and cancellation until expiry or revocation; revocation stampsrevoked_atand keeps the row as history plan_allows?(and theplan_allows_x?sugar) now honors all three entitlement sources — plan, grandfather, grant — so existing app gates pick everything up with zero changes;feature_entitlement_sourceanswers which one applied (:plan | :grandfather | :grant | nil)- Keep
past_duePay subscriptions entitled while the processor retries payment, and prefer a current subscription whose processor price exists in the plan registry when an owner has several - Do not let a stale
past_duestatus re-entitle a Stripe subscription after an already-scheduledvoidpause becomes effective; future pauses remain entitled through their scheduled date - Use Rails' canonical polymorphic owner identity throughout assignments, grants, and admin scopes (including STI), and serialize grant mutations on the owner row so idempotent writes remain race-safe
- Declaring
grandfatherfor a feature the plan stillallowsraises aConfigurationError(one of the two lines is a mistake); unparseable cutoffs raise too, RailsTimeWithZonevalues are accepted, and date-only cutoffs are read as midnight UTC - The install generator now creates
pricing_plans_feature_grants; existing apps add it with the newrails generate pricing_plans:grants(plan-level grandfathering needs no table at all — reads degrade silently without it, grant writes raise with instructions) - Advance the deprecation horizon to 0.7.0 (the APIs deprecated in 0.5.0 live one more minor)
- Make grace windows a validated invariant:
gracedefaults only for:grace_then_block, must be a positive finite numeric duration of at least one second, and cannot be declared at all (includingnil/false) with:block_usage/:just_warn. Previously every limit silently carriedgrace: 7.days, and malformed values could make reporting disagree with enforcement OverageReporteritems now carrygrace_window(the configured window on the target plan, enforced-only by construction) alongside the runtimegrace_active/grace_ends_at, so downgrade and dunning copy can say "after a N-day grace window" without re-deriving enforcement rules- Plan resolution now reads Pay's underlying
payment_processorassociation without invoking Pay's auto-provisioning reader, so asking for the current plan can never create apay_customersrow; PORO/legacy adapters still use their public reader - Add docs/07-repricing.md (the full grandfathering + grants guide), make plan_allows? entitlement-awareness explicit in the model-helpers doc, and bless two long-requested recipes in the docs: reacting to plan changes via Pay lifecycle hooks (#13) and running multiple plan-owner models side by side (#22)
- Add intention-revealing pricing plan override APIs:
override_pricing_plan!,clear_pricing_plan_override!,pricing_plan_overridden?,pricing_plan_override, andpricing_plan_override_source - Require explicit
source:provenance when creating overrides through the new API - Remove the implicit
"manual"source default from newly generated assignment tables; legacy APIs still supply it explicitly for compatibility - Add matching explicit
PlanResolverandAssignmententry points, plus symmetricoverride_pricing_plan_for!andclear_pricing_plan_override_for!helpers onLimitablemodels, for lower-level use cases - Add override-oriented provenance readers to
PlanResolutionwhile preserving its assignment-oriented compatibility readers - Deprecate the ambiguous
assign_pricing_plan!,remove_pricing_plan!,assign_plan_manually!,remove_manual_assignment!,assign_plan_to, andremove_assignment_forAPIs through a gem-specific Active Support deprecator - Guard legacy assignments of the configured default plan with configurable
:allow,:warn, or:raisebehavior; upgraded applications default to:warn, while newly generated initializers choose the safer:raise - Clarify that ordinary free/default accounts should remain unassigned and that intentional default-tier pinning remains supported through the explicit override API
- Never 500 a pricing page when Stripe is unreachable:
Plan#currency_symbolwas the one Stripe lookup without a rescue — a Stripe outage, rate limit, or missing API key (any test/CI environment) raised straight through the pricing page. It now degrades todefault_currency_symbollike every other presentation method (#24) priceandstripe_pricecan be declared together: the numeric price is the local source of truth for display and plan comparison (no network in the request path); the Stripe id stays the billing identity for checkout and subscription matching. Previouslyvalidate_pricing!forced a choice, which meant Stripe-priced plans had no local number — and when the live lookup failed,comparable_price_centssilently became 0 for every paid plan, soupgrade_from?andnext_upgrade_planstopped offering upgrades with no error and no log line (#25)- Only
price_stringremains exclusive, since a label cannot be compared numerically - Known edge: for a both-declared plan the yearly figure derives as 12x monthly; a discounted yearly Stripe price needs
price_components_resolver
- Add plan provenance helpers:
current_pricing_plan_resolution,current_pricing_plan_source, andPlanResolver.resolution_for(plan_owner)now expose whether the effective plan comes from a manual assignment, a Pay subscription, or the default plan - Preserve underlying billing context: resolution objects include the current subscription when present, even when a manual assignment overrides it for entitlements
- Clarify effective plan vs billing state: docs now explicitly distinguish the effective pricing plan from Pay/Stripe subscription status
- Fix stale grace warnings after plan upgrades: Grace/blocked flags now auto-clear when usage drops below limit (self-healing state)
- Fix grace triggering at exact limit:
grace_then_blocknow uses>(over limit) not>=(at limit) - Add lazy grace creation: Grace starts on-demand when checking status, even if callbacks were bypassed
- Add
ExceededStateUtilsmodule: DRY extraction for shared exceeded/blocked logic
- Add
has_plan_assignment?helper: Check if a plan owner has a manual assignment without full plan resolution - Add
plan_assignmenthelper: Retrieve the assignment record directly for inspection
- Manual assignments now override subscriptions: Admin overrides take precedence over Pay/Stripe plans (was incorrectly reversed) -- current plan resolution order: manual assignment → Pay subscription → default plan
- Fix N+1 queries when checking status: Request-scoped caching eliminates N+1 queries in
status()calls (~85% query reduction) - Add automatic callbacks:
on_limit_warning,on_limit_exceeded,on_grace_start,on_blocknow fire automatically when limits change - Add useful admin scopes:
within_all_limits,exceeding_any_limit,in_grace_period,blockedfor dashboard queries - EnforcementState uniqueness: Fixed overly strict validation that blocked multi-limit scenarios
- Added a
metadataalias to plans, and documented its usage
- Fix a bug in the pay gem integration that would always return the default pricing plan regardless of the actual Pay subscription
- Add hidden plans, enabling grandfathering, no-free-users use cases, etc.
- Prevent unlimited limits for limits that were undefined
- Add support for Rails 8+
- Fix a bug where
throw :abortwas causingUncaughtThrowErrorexceptions in controller guards, and instead returnfalsefrombefore_actioncallbacks to halt the filter chain, rather than using the uncaught throw
Initial release