Skip to content

Commit f86a7e7

Browse files
authored
Merge pull request #29 from rameerez/codex/feature-passes
Add first-class feature passes with expiry, capacity, and usage limits
2 parents edea4cf + c339bf1 commit f86a7e7

20 files changed

Lines changed: 880 additions & 15 deletions

.github/workflows/test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ jobs:
4949
ruby -rrubygems/package -e '
5050
spec = Gem::Package.new(ARGV.fetch(0)).spec
5151
required = %w[
52+
lib/pricing_plans/feature_access.rb
53+
lib/generators/pricing_plans/passes/passes_generator.rb
54+
lib/generators/pricing_plans/passes/templates/add_feature_pass_limits.rb.erb
55+
docs/08-feature-passes.md
5256
lib/pricing_plans/models/feature_grant.rb
5357
lib/pricing_plans/plan_owner_identity.rb
5458
lib/generators/pricing_plans/grants/grants_generator.rb

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
## [0.7.0] - 2026-09-05
2+
3+
**Feature passes: bounded, create-only samples on top of feature grants.**
4+
5+
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:
6+
7+
- `issue_feature_pass!` is create-only: it raises `FeatureGrantConflict` instead 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
8+
- A pass can carry named capacity `limits:` (a hash the app measures against at its write boundary) and a cumulative `usage_limit:` whose `usage_count` the 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
9+
- `feature_access(:feature)` returns a read-only `FeatureAccess` snapshot for UI and preflight: `source`, `grant`, `expires_at`, `limit(:key)`, `usage_limit`, `usage_count`, `remaining_allowance`, `available?`, `check!`
10+
- `with_feature_access!(:feature, amount:, usage:)` locks a fresh copy of the owner row, resolves access again, checks the pass limits and cumulative allowance, reserves `amount`, and yields inside a savepoint; an exception escaping the block rolls back the reservation together with the business write. `FeatureLimitExceeded < FeatureDenied` carries `limit_key`, `allowed`, and `requested`
11+
- `FeatureGrant#revise!` changes `expires_at`, `limits`, `usage_limit`, or `note` on an active row while preserving consumption, and refuses expired or revoked rows; `revoke!` is serialized with consumption
12+
- Three additive columns on `pricing_plans_feature_grants` (`limits`, `usage_limit`, `usage_count`) plus a nonnegative check constraint. Fresh installs and `pricing_plans:grants` include them; apps already on the 0.6.x table run `rails generate pricing_plans:passes && rails db:migrate`. Old-schema boolean grants keep working, and bounded writes raise a `ConfigurationError` naming the generator when the columns are missing
13+
- Full guide: `docs/08-feature-passes.md`
14+
- 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)
15+
116
## [0.6.0] - 2026-08-31
217

318
**Repricing without app migrations: grandfathering and per-owner feature grants.**

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,22 @@ org.feature_grants # retained grant/revocation history
279279

280280
Grants live in the `pricing_plans_feature_grants` table (created by the install generator; apps upgrading from < 0.6.0 add it with `rails generate pricing_plans:grants && rails db:migrate`). They attach to the owner, not the plan, so they survive plan changes and cancellations until expiry or revocation. Rows are never deleted by the API: revoking stamps `revoked_at`, preserving each grant/revocation lifecycle. Re-granting while a grant is active updates that row; it is not an event-by-event audit log of field edits.
281281

282+
### Feature passes: time and usage bounded evaluations
283+
284+
Offer a customer a sample without changing billing or replacing an existing promise:
285+
286+
```ruby
287+
org.issue_feature_pass!(:distribution, source: "sales_evaluation",
288+
expires_at: 3.months.from_now,
289+
limits: { storage_bytes: 1.gigabyte }, usage_limit: 2.gigabytes)
290+
```
291+
292+
The [complete feature pass guide](docs/08-feature-passes.md) covers capacity versus
293+
cumulative consumption, atomic write enforcement, operator controls, upgrade
294+
precedence, expiry, revocation, and the additive migration for existing apps.
295+
**Named limits require live measurements at the write boundary**; see
296+
`with_feature_access!` in the guide before enabling a bounded offer.
297+
282298
### Override checks
283299

284300
Some times you'll want to override plan limits / feature gating checks. A common use case is if you're responding to a webhook (like Stripe), you'll want to process the webhook correctly (bypassing the check) and maybe later handle the limit manually.

docs/07-repricing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,8 @@ org.pricing_relationship_started_at
9696
[LicenseSeat](https://licenseseat.com) moved its software-distribution feature from its $9 plan to its $29 plan. Its feature gates (plain `plan_allows?(:distribution)` calls) didn't change at all — the whole repricing lived in configuration and one data migration.
9797

9898
It's also a worked example of choosing between the two tools. LicenseSeat's promise was to *the exact set of customers subscribed on launch day*, frozen, surviving anything — so it ran a small one-time migration that wrote a durable grant per qualifying subscriber (`grant_feature!` with a dated `source`), rather than declaring the date rule. If your promise is the more common "anyone with us from before the change keeps it while they stay subscribed", the one-line `grandfather` declaration is the whole migration.
99+
100+
## Bounded feature passes
101+
102+
For create-only sales offers, capacity limits, and cumulative allowances, see the
103+
[feature passes guide](08-feature-passes.md). Existing grant semantics above remain unchanged.

docs/08-feature-passes.md

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# Feature passes: samples, evaluations, and customer promises
2+
3+
A feature pass grants one plan owner access to a feature independently of billing.
4+
Use it for a sales evaluation, support compensation, a partner promise, or beta
5+
access. A pass can be permanent, expire at a deadline, carry named capacity limits,
6+
and/or have a cumulative usage allowance. These are existing FeatureGrant rows,
7+
not another subscription system. Available in 0.7.0.
8+
9+
## Issue a sample without replacing a promise
10+
11+
```ruby
12+
pass = organization.issue_feature_pass!(
13+
:distribution,
14+
source: "sales_evaluation",
15+
note: "Evaluate managed updates; follow up after the first release",
16+
expires_at: 3.months.from_now,
17+
limits: { storage_bytes: 1.gigabyte, max_artifact_bytes: 200.megabytes },
18+
usage_limit: 2.gigabytes
19+
)
20+
```
21+
22+
This permits access until the deadline, at most 1 GB stored concurrently, and at
23+
most 2 GB cumulatively reserved through the write API below. A single artifact
24+
may be at most 200 MB. The host must supply its live capacity measurements.
25+
26+
`issue_feature_pass!` raises `PricingPlans::FeatureGrantConflict` if there is
27+
already an active grant for this owner and feature. It never silently shortens
28+
a permanent grant or replaces a support promise. `source:` is required.
29+
30+
Omit `expires_at` for permanent access. Omit `usage_limit` for no cumulative cap.
31+
An omitted named limit is unlimited for an entitled feature; a feature with no
32+
entitlement is still denied. Use `0` to prohibit consumption or a capacity, and
33+
`:unlimited` explicitly where appropriate. Counts must be nonnegative integers
34+
within signed bigint range; strings, fractional amounts, negatives, and unknown
35+
options are rejected by the public pass APIs.
36+
37+
The existing `grant_feature!` remains an upsert. It now accepts `limits:` and
38+
`usage_limit:` too. When omitted during an upsert, those two settings and consumed
39+
usage are preserved. Existing semantics for source, note and expiration remain
40+
unchanged (omitting expiration on that legacy API makes the grant permanent).
41+
Use the create-only API for an operator's "issue" button.
42+
43+
## Read access and show an offer
44+
45+
```ruby
46+
organization.plan_allows?(:distribution) # same boolean API as before
47+
access = organization.feature_access(:distribution)
48+
access.allowed? # entitlement exists; does not mean every operation fits
49+
access.source # :plan | :grandfather | :grant | nil
50+
access.grant # selected grant row, or nil
51+
access.expires_at # grant deadline, or nil
52+
access.limit(:storage_bytes) # integer or :unlimited
53+
access.usage_count # cumulative grant consumption
54+
access.remaining_allowance # integer or :unlimited
55+
access.available?(amount: upload.bytesize,
56+
usage: { storage_bytes: stored_bytes + upload.bytesize,
57+
max_artifact_bytes: upload.bytesize })
58+
```
59+
60+
`FeatureAccess` is a snapshot for UI and preflight. Do not cache it across requests
61+
or use an old snapshot to authorize a write. `available?` returns false on denial;
62+
`check!` raises `FeatureDenied` or its `FeatureLimitExceeded` subclass, which exposes
63+
`feature_key`, `plan_owner`, `limit_key`, `allowed`, and `requested`.
64+
65+
Boolean access is intentionally separate from consumption. A fully used upload
66+
allowance still permits zero-cost operations, such as finalizing or publishing
67+
an already reserved artifact. A new upload fails when it would exceed the
68+
allowance. At `expires_at` exactly, the grant is inactive, including for zero-cost
69+
writes. There is no expiry worker: the next entitlement check sees the deadline.
70+
71+
## Enforce and reserve in the same transaction
72+
73+
```ruby
74+
organization.with_feature_access!(
75+
:distribution,
76+
amount: upload.bytesize,
77+
usage: -> {
78+
{
79+
storage_bytes: organization.artifacts.sum(:byte_size) + upload.bytesize,
80+
max_artifact_bytes: upload.bytesize
81+
}
82+
}
83+
) do |access|
84+
organization.artifacts.create!(byte_size: upload.bytesize, filename: upload.filename)
85+
end
86+
```
87+
88+
The gem locks a fresh copy of the owner row, resolves access again, reads the
89+
usage callable, checks the named limits and cumulative allowance, reserves the
90+
amount, then yields. It bypasses query caches so a preflight read cannot stay
91+
stale after waiting for a competing writer. It does not reload the caller's
92+
unsaved attributes. The block's return value is returned.
93+
Grant consumption is an internal operation;
94+
use `with_feature_access!`, not a direct counter update, to spend an allowance.
95+
96+
All competing writes must use this API. Capacity values must be measured inside
97+
the callable, on the owner's database connection. `amount:` and each value in
98+
`usage:` are separate: the former is cumulative consumption, the latter is the
99+
prospective capacity after this operation (or its size for a per-operation cap).
100+
Only the dimensions the host supplies are checked; the gem cannot discover your
101+
storage system or infer which operation uses a named limit. Declaring `limits:`
102+
alone does not install callbacks on arbitrary models. Include each applicable
103+
capacity dimension at its write boundary. Existing association limits continue
104+
to use their own documented model integration.
105+
106+
The grant update and same-database business write roll back together on an
107+
exception. A savepoint isolates failure even if an outer transaction rescues it.
108+
A block that returns false still commits; use bang writes and raise on failure.
109+
For controller actions that rescue internally, raise `ActiveRecord::Rollback`
110+
inside the block when the rendered response is unsuccessful. External storage
111+
and HTTP requests are not database transactions: reserve before handing out an
112+
upload URL, and define abandoned-upload policy explicitly. Keep lock duration
113+
short; stream file bytes outside it.
114+
115+
This API does not deduplicate arbitrary operations. Reuse an existing operation
116+
record/idempotency key before reserving again. Uniqueness violations that escape
117+
the block roll back consumption. Deleting artifacts does not refund cumulative
118+
usage. Use live stored capacity when deletions should free room. The allowance
119+
belongs to this grant lifecycle, with no periodic reset, transferable balance,
120+
refund API, or purchase ledger; use `usage_credits` for a credit economy.
121+
122+
Row-level concurrency guarantees require a database such as PostgreSQL or MySQL.
123+
SQLite does not provide equivalent row locks. Owners and grants must use the
124+
same database/connection for atomicity. Direct SQL, `update_columns`, and writes
125+
that bypass this API are outside the contract.
126+
127+
## Paid access wins
128+
129+
Precedence remains plan, then qualifying grandfather, then active grant. A paid
130+
plan's access never spends or inherits a pass's allowance, and a pass never
131+
restricts an owner whose plan already carries the feature. Named limits belong
132+
to the pass alone: plan and grandfather access carry none, so `limit(:key)`
133+
answers `:unlimited` for them and your app keeps owning its plan quotas exactly
134+
as it did before passes existed. Limits do not stack and are not merged between
135+
sources. If the owner later downgrades, an unexpired, unrevoked pass resumes
136+
with its previous consumed usage. Buying a plan does not delete the pass. A plan
137+
that merely has a higher price does not win unless it actually allows the feature.
138+
139+
## Revise or revoke explicitly
140+
141+
```ruby
142+
pass.revise!(expires_at: 6.months.from_now, usage_limit: 4.gigabytes,
143+
note: "Customer requested time for the next release")
144+
organization.revoke_feature!(:distribution, note: "Evaluation ended early")
145+
organization.feature_grants # retained active, expired, and revoked lifecycles
146+
```
147+
148+
`revise!` accepts only `expires_at`, `limits`, `usage_limit`, and `note`, preserves
149+
consumption, and refuses expired/revoked rows. Lowering the allowance below used
150+
usage blocks further consumption; it does not rewrite history. Passing nil to
151+
`usage_limit` removes that cap. Passing `{}` to `limits` clears named caps.
152+
153+
Revocation is serialized with consumption. Revoking a grant cannot remove plan
154+
or grandfather access. Issue a new pass after expiry/revocation for a new
155+
allowance and history row. The table retains lifecycle history, not an immutable
156+
log of every edit: revisions and legacy upserts update their row. Apps should
157+
record the operator and reason, and append change notes or use their audit system.
158+
159+
## Installation and backwards compatibility
160+
161+
| Starting point | Command |
162+
| --- | --- |
163+
| New app | `rails generate pricing_plans:install` |
164+
| App without feature grants (before 0.6.0) | `rails generate pricing_plans:grants` |
165+
| App with the 0.6.x grants table | `rails generate pricing_plans:passes` |
166+
167+
Then run `rails db:migrate`. Run only the applicable generator, not all three.
168+
The additive upgrade adds JSON `limits` (default `{}`), nullable bigint
169+
`usage_limit`, bigint `usage_count` (default `0`), and a nonnegative usage check.
170+
It does not alter owners, existing expirations, revocations, or billing rows.
171+
Existing grants remain unbounded unless the app deliberately supplies a policy.
172+
173+
Old-schema boolean grants continue to work after upgrading gem code. New
174+
capacity/consumption writes raise an actionable migration error before doing work
175+
if the pass columns are missing. Deploy the schema before enabling pass UI and
176+
metered write paths. No destructive backfill or scheduled expiry job is needed.
177+
178+
## A complete SaaS offering
179+
180+
The gem owns entitlement resolution, capacity comparison, deadlines, consumption,
181+
locking, and lifecycle APIs. The app owns authenticated operator controls,
182+
allowed feature/metric choices, units, emails, customer wording, pricing links,
183+
and storage measurements. Never present an editable metric the app does not
184+
actually enforce. An organization-owned SaaS should grant to the organization,
185+
not a member's User record; a user-owned SaaS can include PlanOwner on User.
186+
187+
For distribution, a useful default is to block new uploads/publishing at expiry
188+
while continuing to serve existing feeds and downloads. Enforce this by putting
189+
gates on write endpoints only. Do not delete customer data as an expiry side
190+
effect. Explain the deadline and paid continuation option before the evaluation.
191+
A whole-plan Stripe trial or expiring plan override is a separate product:
192+
feature passes do not create trials, change subscriptions, or expire assignments.

lib/generators/pricing_plans/grants/templates/create_pricing_plans_feature_grants.rb.erb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@ class CreatePricingPlansFeatureGrants < ActiveRecord::Migration<%= migration_ver
1010
t.string :source, null: false
1111
t.text :note
1212
t.datetime :expires_at
13+
t.send(json_column_type, :limits, default: {}, null: false)
14+
t.bigint :usage_limit
15+
t.bigint :usage_count, default: 0, null: false
1316
t.datetime :revoked_at
1417

1518
t.timestamps
1619
end
1720

21+
add_check_constraint :pricing_plans_feature_grants,
22+
"usage_count >= 0 AND (usage_limit IS NULL OR usage_limit >= 0)",
23+
name: "pricing_plans_feature_pass_usage_nonnegative"
24+
1825
add_index :pricing_plans_feature_grants,
1926
[ :plan_owner_type, :plan_owner_id, :feature_key ],
2027
name: "idx_pricing_plans_feature_grants_lookup"
@@ -29,4 +36,9 @@ class CreatePricingPlansFeatureGrants < ActiveRecord::Migration<%= migration_ver
2936
foreign_key_type = setting || :bigint
3037
[ primary_key_type, foreign_key_type ]
3138
end
39+
40+
def json_column_type
41+
return :jsonb if connection.adapter_name.downcase.include?("postgresql")
42+
:json
43+
end
3244
end

lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,18 @@ class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
7777
t.string :source, null: false
7878
t.text :note
7979
t.datetime :expires_at
80+
t.send(json_column_type, :limits, default: {}, null: false)
81+
t.bigint :usage_limit
82+
t.bigint :usage_count, default: 0, null: false
8083
t.datetime :revoked_at
8184

8285
t.timestamps
8386
end
8487

88+
add_check_constraint :pricing_plans_feature_grants,
89+
"usage_count >= 0 AND (usage_limit IS NULL OR usage_limit >= 0)",
90+
name: "pricing_plans_feature_pass_usage_nonnegative"
91+
8592
add_index :pricing_plans_feature_grants,
8693
[ :plan_owner_type, :plan_owner_id, :feature_key ],
8794
name: "idx_pricing_plans_feature_grants_lookup"

lib/generators/pricing_plans/install/templates/initializer.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,8 @@
147147
# When set to true, detailed debug output will be printed to stdout, which can be helpful for troubleshooting.
148148
# config.debug = false
149149
end
150+
151+
# Individual sales evaluations (after migration; run from your app/admin service):
152+
# owner.issue_feature_pass!(:api_access, source: "sales", expires_at: 3.months.from_now)
153+
# Capacity/consumption offers require with_feature_access! at the write boundary.
154+
# Full guide: https://github.com/rameerez/pricing_plans/blob/main/docs/08-feature-passes.md
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
require "rails/generators/base"
4+
require "rails/generators/active_record"
5+
6+
module PricingPlans
7+
module Generators
8+
class PassesGenerator < Rails::Generators::Base
9+
include ActiveRecord::Generators::Migration
10+
11+
source_root File.expand_path("templates", __dir__)
12+
desc "Add capacity limits and cumulative usage to existing feature grants"
13+
14+
def self.next_migration_number(dir)
15+
ActiveRecord::Generators::Base.next_migration_number(dir)
16+
end
17+
18+
def create_migration_file
19+
migration_template "add_feature_pass_limits.rb.erb",
20+
File.join(db_migrate_path, "add_feature_pass_limits.rb"),
21+
migration_version: migration_version
22+
end
23+
24+
private
25+
26+
def migration_version
27+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
28+
end
29+
end
30+
end
31+
end

0 commit comments

Comments
 (0)