pricing_plans 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6b80d22fc74b3afafd8f850f0bbe401c9af56d9dad8102233c48e4e332f1b62
4
- data.tar.gz: a03538e62a477cb3ba9f90df6e27de51a48d7b348f2aef96dd7cca0dd997bf88
3
+ metadata.gz: 69a00b6a4bbe7bc513e707744b890a0fbcea4161f322f5a5d3384ae1775be130
4
+ data.tar.gz: 6647732b7368f398bb62eaa1968af7b0132ef68f2ad89f858aa507922c794e4d
5
5
  SHA512:
6
- metadata.gz: 605f1beb4fdc85b9b8b26b2e8da96fb620f9b8bd28ecadf4688d5b00cf002aaa979155abeccef100f50e3a9b9932095506b691e0bbd84f4727f0cebdaaba40bd
7
- data.tar.gz: 3b8ecc71305850c3760d3a96ee530c2069a099806733223280b834cd368fbe9773d8483f5a7fa02744fd0704206d6a142a0933d8837133c28ca85a93fc0d1860
6
+ metadata.gz: f77dce78f0d0fa86cd195f6b7fab99992634cb17ed5543973fbb3afa0774da80ed1236ea5cb712df1e2b9e58bb2925c7a476942f8aab0720926332c3cbc89a35
7
+ data.tar.gz: 42add5c893313c1dddbbe7af5c54882872e6d75b6551d70c074e5e44ed96e54be72e778b40e7aacbb5daf222499b0ff7d208f165d42c766b9fb62217b79e5a0a
data/CHANGELOG.md CHANGED
@@ -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
+
1
16
  ## [0.6.0] - 2026-08-31
2
17
 
3
18
  **Repricing without app migrations: grandfathering and per-owner feature grants.**
data/README.md CHANGED
@@ -279,6 +279,22 @@ org.feature_grants # retained grant/revocation history
279
279
 
280
280
  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.
281
281
 
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
+
282
298
  ### Override checks
283
299
 
284
300
  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.
data/docs/07-repricing.md CHANGED
@@ -96,3 +96,8 @@ org.pricing_relationship_started_at
96
96
  [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.
97
97
 
98
98
  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.
@@ -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.
@@ -10,11 +10,18 @@ class CreatePricingPlansFeatureGrants < ActiveRecord::Migration<%= migration_ver
10
10
  t.string :source, null: false
11
11
  t.text :note
12
12
  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
13
16
  t.datetime :revoked_at
14
17
 
15
18
  t.timestamps
16
19
  end
17
20
 
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
+
18
25
  add_index :pricing_plans_feature_grants,
19
26
  [ :plan_owner_type, :plan_owner_id, :feature_key ],
20
27
  name: "idx_pricing_plans_feature_grants_lookup"
@@ -29,4 +36,9 @@ class CreatePricingPlansFeatureGrants < ActiveRecord::Migration<%= migration_ver
29
36
  foreign_key_type = setting || :bigint
30
37
  [ primary_key_type, foreign_key_type ]
31
38
  end
39
+
40
+ def json_column_type
41
+ return :jsonb if connection.adapter_name.downcase.include?("postgresql")
42
+ :json
43
+ end
32
44
  end
@@ -77,11 +77,18 @@ class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
77
77
  t.string :source, null: false
78
78
  t.text :note
79
79
  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
80
83
  t.datetime :revoked_at
81
84
 
82
85
  t.timestamps
83
86
  end
84
87
 
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
+
85
92
  add_index :pricing_plans_feature_grants,
86
93
  [ :plan_owner_type, :plan_owner_id, :feature_key ],
87
94
  name: "idx_pricing_plans_feature_grants_lookup"
@@ -147,3 +147,8 @@ PricingPlans.configure do |config|
147
147
  # When set to true, detailed debug output will be printed to stdout, which can be helpful for troubleshooting.
148
148
  # config.debug = false
149
149
  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
@@ -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,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddFeaturePassLimits < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ add_column :pricing_plans_feature_grants, :limits, json_column_type, default: {}, null: false
6
+ add_column :pricing_plans_feature_grants, :usage_limit, :bigint
7
+ add_column :pricing_plans_feature_grants, :usage_count, :bigint, default: 0, null: false
8
+ add_check_constraint :pricing_plans_feature_grants,
9
+ "usage_count >= 0 AND (usage_limit IS NULL OR usage_limit >= 0)",
10
+ name: "pricing_plans_feature_pass_usage_nonnegative"
11
+ end
12
+
13
+ private
14
+
15
+ def json_column_type
16
+ return :jsonb if connection.adapter_name.downcase.include?("postgresql")
17
+ :json
18
+ end
19
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ class FeatureGrantConflict < Error; end
5
+
6
+ class FeatureLimitExceeded < FeatureDenied
7
+ attr_reader :limit_key, :allowed, :requested
8
+
9
+ def initialize(feature_key:, plan_owner:, limit_key:, allowed:, requested:)
10
+ @limit_key = limit_key
11
+ @allowed = allowed
12
+ @requested = requested
13
+ super("#{feature_key.to_s.humanize}: #{limit_key.to_s.humanize.downcase} limit exceeded " \
14
+ "(#{requested} requested, #{allowed} allowed).", feature_key: feature_key, plan_owner: plan_owner)
15
+ end
16
+ end
17
+
18
+ # One snapshot for presentation and preflight checks. Writes must use
19
+ # PlanOwner#with_feature_access! to resolve again under the owner row lock.
20
+ class FeatureAccess
21
+ MAX_INTEGER = (2**63) - 1
22
+ attr_reader :owner, :feature_key, :source, :grant, :limits
23
+
24
+ def self.validate_amount!(amount)
25
+ return amount if amount.is_a?(Integer) && amount.between?(0, MAX_INTEGER)
26
+
27
+ raise ArgumentError, "usage must be a nonnegative integer no larger than #{MAX_INTEGER}"
28
+ end
29
+
30
+ def self.normalize_limits(limits)
31
+ raise ArgumentError, "limits must be a Hash" unless limits.is_a?(Hash)
32
+
33
+ limits.each_with_object({}) do |(key, amount), result|
34
+ unless (key.is_a?(String) || key.is_a?(Symbol)) && key.to_s.match?(/\A[a-z][a-z0-9_]*\z/)
35
+ raise ArgumentError, "limit keys must be lowercase names such as storage_bytes"
36
+ end
37
+ raise ArgumentError, "duplicate limit #{key}" if result.key?(key.to_s)
38
+
39
+ result[key.to_s] = if amount == :unlimited || amount == "unlimited"
40
+ "unlimited"
41
+ else
42
+ validate_amount!(amount)
43
+ end
44
+ end
45
+ end
46
+
47
+ # Named limits belong to a pass. Plan and grandfather access carry none:
48
+ # the gem answers "is this owner entitled?", and the app owns its plan
49
+ # quotas the way it always has.
50
+ def initialize(owner, feature_key)
51
+ @owner = owner
52
+ @feature_key = feature_key.to_sym
53
+ @source = owner.feature_entitlement_source(@feature_key)
54
+ @grant = owner.feature_grants.active.for_feature(@feature_key).first if source == :grant
55
+ @source = nil if source == :grant && !grant
56
+ @limits = (grant ? grant.pass_limits : {}).freeze
57
+ end
58
+
59
+ def allowed? = !source.nil?
60
+ def expires_at = grant&.expires_at
61
+ def usage_limit = grant&.pass_usage_limit
62
+ def usage_count = grant ? grant.pass_usage_count : 0
63
+
64
+ def limit(key)
65
+ value = limits.fetch(key.to_s, "unlimited")
66
+ value == "unlimited" ? :unlimited : value
67
+ end
68
+
69
+ def remaining_allowance
70
+ usage_limit ? [usage_limit - usage_count, 0].max : :unlimited
71
+ end
72
+
73
+ def available?(amount: 0, usage: {})
74
+ check!(amount: amount, usage: usage)
75
+ true
76
+ rescue FeatureDenied
77
+ false
78
+ end
79
+
80
+ def check!(amount: 0, usage: {})
81
+ self.class.validate_amount!(amount)
82
+ raise ArgumentError, "usage must be a Hash" unless usage.is_a?(Hash)
83
+
84
+ unless allowed?
85
+ raise FeatureDenied.new("#{feature_key.to_s.humanize} is not available.",
86
+ feature_key: feature_key, plan_owner: owner)
87
+ end
88
+
89
+ check_capacity!(usage)
90
+ check_limit!(:usage, usage_limit, usage_count + amount) if usage_limit && amount.positive?
91
+ self
92
+ end
93
+
94
+ private
95
+
96
+ def check_capacity!(usage)
97
+ usage.each do |key, value|
98
+ self.class.validate_amount!(value)
99
+ check_limit!(key.to_sym, limit(key), value)
100
+ end
101
+ end
102
+
103
+ def check_limit!(key, allowed, requested)
104
+ return if allowed == :unlimited || requested <= allowed
105
+
106
+ raise FeatureLimitExceeded.new(feature_key: feature_key, plan_owner: owner,
107
+ limit_key: key, allowed: allowed, requested: requested)
108
+ end
109
+ end
110
+ end
@@ -18,6 +18,7 @@ module PricingPlans
18
18
  validates :feature_key, presence: true
19
19
  validates :source, presence: true
20
20
  validate :expires_at_must_be_parseable
21
+ validate :pass_options_must_be_valid
21
22
 
22
23
  scope :for_feature, ->(feature_key) { where(feature_key: feature_key.to_s) }
23
24
  scope :active, lambda {
@@ -29,18 +30,67 @@ module PricingPlans
29
30
  end
30
31
 
31
32
  def revoke!(note: nil)
32
- return self if revoked_at.present?
33
+ self.class.with_owner_lock(plan_owner) do
34
+ reload
35
+ update!(revoked_at: Time.current, note: [self.note, note].compact.presence&.join(" | ")) unless revoked_at.present?
36
+ end
37
+ self
38
+ end
39
+
40
+ def pass_limits
41
+ has_attribute?(:limits) ? FeatureAccess.normalize_limits(self[:limits] || {}) : {}
42
+ end
33
43
 
34
- update!(revoked_at: Time.current, note: [self.note, note].compact.presence&.join(" | "))
44
+ def pass_usage_limit = has_attribute?(:usage_limit) ? self[:usage_limit] : nil
45
+ def pass_usage_count = has_attribute?(:usage_count) ? self[:usage_count] : 0
46
+
47
+ # Internal to PlanOwner#with_feature_access!, which calls this on a row it
48
+ # loaded fresh under the owner lock after FeatureAccess#check! passed. The
49
+ # lock is what makes check-then-increment safe; this method neither
50
+ # re-acquires it nor reloads. Keep this private: the database constraint
51
+ # rejects negative counters, but cannot enforce the allowance or expiry.
52
+ def record_usage!(amount)
53
+ self.class.ensure_pass_columns!
54
+ FeatureAccess.validate_amount!(amount)
55
+ raise FeatureDenied, "This feature pass is no longer active." unless active?
56
+
57
+ total = pass_usage_count + amount
58
+ FeatureAccess.validate_amount!(total)
59
+ if pass_usage_limit && total > pass_usage_limit
60
+ raise FeatureLimitExceeded.new(feature_key: feature_key, plan_owner: plan_owner,
61
+ limit_key: :usage, allowed: pass_usage_limit, requested: total)
62
+ end
63
+
64
+ increment!(:usage_count, amount, touch: true)
65
+ end
66
+ private :record_usage!
67
+
68
+ # Revise a specific lifecycle without resetting consumption or resurrecting it.
69
+ def revise!(**options)
70
+ unknown = options.keys - [:expires_at, :note, :limits, :usage_limit]
71
+ raise ArgumentError, "unknown revision options: #{unknown.join(', ')}" if unknown.any?
72
+ self.class.ensure_pass_columns! if (options.keys & [:limits, :usage_limit]).any?
73
+ self.class.with_owner_lock(plan_owner) do
74
+ reload
75
+ raise FeatureGrantConflict, "This pass is no longer active; issue a new pass." unless active?
76
+ options[:limits] = FeatureAccess.normalize_limits(options[:limits]) if options.key?(:limits)
77
+ FeatureAccess.validate_amount!(options[:usage_limit]) if options.key?(:usage_limit) && !options[:usage_limit].nil?
78
+ update!(**options)
79
+ end
35
80
  self
36
81
  end
37
82
 
38
83
  class << self
39
84
  # Idempotent: updates the active grant for (owner, feature) if one
40
85
  # exists, otherwise creates it. Revoked grants stay behind as history.
41
- def grant_to!(plan_owner, feature_key, source: "manual", note: nil, expires_at: nil)
86
+ def grant_to!(plan_owner, feature_key, source: "manual", note: nil, expires_at: nil, replace: true, **options)
42
87
  ensure_table!
43
88
  ensure_persisted_owner!(plan_owner)
89
+ unknown = options.keys - [:limits, :usage_limit]
90
+ raise ArgumentError, "unknown pass options: #{unknown.join(', ')}" if unknown.any?
91
+ ensure_pass_columns! if options.any?
92
+ options[:limits] = FeatureAccess.normalize_limits(options[:limits]) if options.key?(:limits)
93
+ FeatureAccess.validate_amount!(options[:usage_limit]) if options.key?(:usage_limit) && !options[:usage_limit].nil?
44
94
 
45
95
  # Serialize grant writes through the owner row. A lookup followed by
46
96
  # create is otherwise race-prone, and a portable partial unique index
@@ -49,7 +99,8 @@ module PricingPlans
49
99
  with_locked_owner(plan_owner) do
50
100
  grant = active.find_by(owner_conditions(plan_owner).merge(feature_key: feature_key.to_s))
51
101
  if grant
52
- grant.update!(source: source.to_s, note: note, expires_at: expires_at)
102
+ raise FeatureGrantConflict, "An active grant already exists for #{feature_key}; revise it explicitly." unless replace
103
+ grant.update!(source: source.to_s, note: note, expires_at: expires_at, **options)
53
104
  grant
54
105
  else
55
106
  create!(
@@ -57,7 +108,8 @@ module PricingPlans
57
108
  feature_key: feature_key.to_s,
58
109
  source: source.to_s,
59
110
  note: note,
60
- expires_at: expires_at
111
+ expires_at: expires_at,
112
+ **options
61
113
  )
62
114
  end
63
115
  end
@@ -91,6 +143,17 @@ module PricingPlans
91
143
  false
92
144
  end
93
145
 
146
+ def ensure_pass_columns!
147
+ return if %w[limits usage_limit usage_count].all? { |name| column_names.include?(name) }
148
+
149
+ raise ConfigurationError, "Run `rails generate pricing_plans:passes && rails db:migrate` to add feature pass limits."
150
+ end
151
+
152
+ def with_owner_lock(plan_owner, &block)
153
+ ensure_persisted_owner!(plan_owner)
154
+ with_locked_owner(plan_owner, &block)
155
+ end
156
+
94
157
  private
95
158
 
96
159
  def ensure_table!
@@ -117,17 +180,27 @@ module PricingPlans
117
180
  def with_locked_owner(plan_owner)
118
181
  owner_class = plan_owner.class.base_class
119
182
 
120
- owner_class.transaction do
121
- # Lock a fresh copy so granting a feature never reloads or rejects a
122
- # caller that happens to have unrelated unsaved changes.
123
- owner_class.unscoped.lock.find(plan_owner.id)
124
- yield
183
+ owner_class.uncached do
184
+ owner_class.transaction(requires_new: true) do
185
+ # Lock a fresh copy without changing the caller's unsaved attributes.
186
+ # Bypass preflight query caches after waiting for another writer.
187
+ owner_class.unscoped.lock.find(plan_owner.id)
188
+ yield
189
+ end
125
190
  end
126
191
  end
127
192
  end
128
193
 
129
194
  private
130
195
 
196
+ def pass_options_must_be_valid
197
+ pass_limits
198
+ FeatureAccess.validate_amount!(pass_usage_count)
199
+ FeatureAccess.validate_amount!(pass_usage_limit) unless pass_usage_limit.nil?
200
+ rescue ArgumentError => error
201
+ errors.add(:base, error.message)
202
+ end
203
+
131
204
  def expires_at_must_be_parseable
132
205
  raw_value = expires_at_before_type_cast
133
206
  return if raw_value.nil? || (raw_value.is_a?(String) && raw_value.blank?)
@@ -182,6 +182,11 @@ module PricingPlans
182
182
  # Feature methods
183
183
  def allows(*feature_keys)
184
184
  feature_keys.flatten.each do |key|
185
+ unless key.is_a?(Symbol) || key.is_a?(String)
186
+ raise ConfigurationError,
187
+ "`allows` takes feature names only (got #{key.inspect}). Plan quotas live in " \
188
+ "`limits`; per-owner capacities live on feature passes (`issue_feature_pass!(..., limits: {})`)."
189
+ end
185
190
  @features.add(key.to_sym)
186
191
  end
187
192
  end
@@ -308,8 +308,32 @@ module PricingPlans
308
308
  # Per-owner feature grants: individual, auditable exceptions on top of
309
309
  # plan resolution (comps, beta access, sales exceptions, promises that
310
310
  # must survive cancellation). See PricingPlans::FeatureGrant.
311
- def grant_feature!(feature_key, source: "manual", note: nil, expires_at: nil)
312
- FeatureGrant.grant_to!(self, feature_key, source: source, note: note, expires_at: expires_at)
311
+ def grant_feature!(feature_key, source: "manual", note: nil, expires_at: nil, **options)
312
+ FeatureGrant.grant_to!(self, feature_key, source: source, note: note, expires_at: expires_at, **options)
313
+ end
314
+
315
+ # Create-only sales/support pass: never replace an existing customer promise.
316
+ def issue_feature_pass!(feature_key, source:, **options)
317
+ FeatureGrant.grant_to!(self, feature_key, source: source, **options, replace: false)
318
+ end
319
+
320
+ def feature_access(feature_key)
321
+ FeatureAccess.new(self, feature_key)
322
+ end
323
+
324
+ # The block and cumulative reservation share a transaction. Read live capacity
325
+ # in the usage callable, after locking; do not pass a precomputed count.
326
+ # All competing writers must use this API and the owner's database connection.
327
+ def with_feature_access!(feature_key, amount: 0, usage: -> { {} })
328
+ raise ArgumentError, "a block is required" unless block_given?
329
+ raise ArgumentError, "usage must be callable so it is read under the lock" unless usage.respond_to?(:call)
330
+ FeatureAccess.validate_amount!(amount)
331
+ FeatureGrant.with_owner_lock(self) do
332
+ access = feature_access(feature_key)
333
+ access.check!(amount: amount, usage: usage.call)
334
+ access.grant.__send__(:record_usage!, amount) if access.grant && amount.positive?
335
+ yield access
336
+ end
313
337
  end
314
338
 
315
339
  # Revokes active grants for the feature (audit rows are kept, stamped
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PricingPlans
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/pricing_plans.rb CHANGED
@@ -30,6 +30,9 @@ module PricingPlans
30
30
  end
31
31
  class InvalidOperation < Error; end
32
32
 
33
+ autoload :FeatureAccess, "pricing_plans/feature_access"
34
+ autoload :FeatureLimitExceeded, "pricing_plans/feature_access"
35
+ autoload :FeatureGrantConflict, "pricing_plans/feature_access"
33
36
  autoload :Configuration, "pricing_plans/configuration"
34
37
  autoload :Registry, "pricing_plans/registry"
35
38
  autoload :Plan, "pricing_plans/plan"
@@ -75,7 +78,7 @@ module PricingPlans
75
78
  # warnings independently and lets Rails include them in its deprecator
76
79
  # collection.
77
80
  def deprecator
78
- @deprecator ||= ActiveSupport::Deprecation.new("0.7.0", "pricing_plans")
81
+ @deprecator ||= ActiveSupport::Deprecation.new("0.8.0", "pricing_plans")
79
82
  end
80
83
 
81
84
  def configure(&block)
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pricing_plans
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-01 00:00:00.000000000 Z
10
+ date: 2026-09-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -78,6 +78,7 @@ files:
78
78
  - docs/05-semantic-pricing.md
79
79
  - docs/06-gem-compatibility.md
80
80
  - docs/07-repricing.md
81
+ - docs/08-feature-passes.md
81
82
  - docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg
82
83
  - docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg
83
84
  - docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg
@@ -90,6 +91,8 @@ files:
90
91
  - lib/generators/pricing_plans/install/install_generator.rb
91
92
  - lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb
92
93
  - lib/generators/pricing_plans/install/templates/initializer.rb
94
+ - lib/generators/pricing_plans/passes/passes_generator.rb
95
+ - lib/generators/pricing_plans/passes/templates/add_feature_pass_limits.rb.erb
93
96
  - lib/pricing_plans.rb
94
97
  - lib/pricing_plans/association_limit_registry.rb
95
98
  - lib/pricing_plans/callbacks.rb
@@ -99,6 +102,7 @@ files:
99
102
  - lib/pricing_plans/dsl.rb
100
103
  - lib/pricing_plans/engine.rb
101
104
  - lib/pricing_plans/exceeded_state_utils.rb
105
+ - lib/pricing_plans/feature_access.rb
102
106
  - lib/pricing_plans/grace_manager.rb
103
107
  - lib/pricing_plans/integer_refinements.rb
104
108
  - lib/pricing_plans/job_guards.rb
@@ -129,7 +133,7 @@ licenses:
129
133
  - MIT
130
134
  metadata:
131
135
  homepage_uri: https://github.com/rameerez/pricing_plans
132
- source_code_uri: https://github.com/rameerez/pricing_plans/tree/v0.6.0
136
+ source_code_uri: https://github.com/rameerez/pricing_plans/tree/v0.7.0
133
137
  changelog_uri: https://github.com/rameerez/pricing_plans/blob/main/CHANGELOG.md
134
138
  bug_tracker_uri: https://github.com/rameerez/pricing_plans/issues
135
139
  documentation_uri: https://github.com/rameerez/pricing_plans#readme