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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +16 -0
- data/docs/07-repricing.md +5 -0
- data/docs/08-feature-passes.md +192 -0
- data/lib/generators/pricing_plans/grants/templates/create_pricing_plans_feature_grants.rb.erb +12 -0
- data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +7 -0
- data/lib/generators/pricing_plans/install/templates/initializer.rb +5 -0
- data/lib/generators/pricing_plans/passes/passes_generator.rb +31 -0
- data/lib/generators/pricing_plans/passes/templates/add_feature_pass_limits.rb.erb +19 -0
- data/lib/pricing_plans/feature_access.rb +110 -0
- data/lib/pricing_plans/models/feature_grant.rb +83 -10
- data/lib/pricing_plans/plan.rb +5 -0
- data/lib/pricing_plans/plan_owner.rb +26 -2
- data/lib/pricing_plans/version.rb +1 -1
- data/lib/pricing_plans.rb +4 -1
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69a00b6a4bbe7bc513e707744b890a0fbcea4161f322f5a5d3384ae1775be130
|
|
4
|
+
data.tar.gz: 6647732b7368f398bb62eaa1968af7b0132ef68f2ad89f858aa507922c794e4d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/generators/pricing_plans/grants/templates/create_pricing_plans_feature_grants.rb.erb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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?)
|
data/lib/pricing_plans/plan.rb
CHANGED
|
@@ -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
|
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.
|
|
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.
|
|
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-
|
|
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.
|
|
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
|