pricing_plans 0.5.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/.rubocop.yml +4 -7
- data/.simplecov +6 -5
- data/Appraisals +5 -1
- data/CHANGELOG.md +34 -0
- data/README.md +64 -1
- data/docs/01-define-pricing-plans.md +6 -1
- data/docs/03-model-helpers.md +16 -0
- data/docs/06-gem-compatibility.md +35 -0
- data/docs/07-repricing.md +103 -0
- data/docs/08-feature-passes.md +192 -0
- data/gemfiles/rails_7.1.gemfile +26 -0
- data/gemfiles/rails_7.2.gemfile +3 -2
- data/gemfiles/rails_8.1.gemfile +2 -1
- data/lib/generators/pricing_plans/grants/grants_generator.rb +43 -0
- data/lib/generators/pricing_plans/grants/templates/create_pricing_plans_feature_grants.rb.erb +44 -0
- data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +29 -8
- data/lib/generators/pricing_plans/install/templates/initializer.rb +11 -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/engine.rb +1 -0
- data/lib/pricing_plans/feature_access.rb +110 -0
- data/lib/pricing_plans/models/assignment.rb +2 -4
- data/lib/pricing_plans/models/feature_grant.rb +212 -0
- data/lib/pricing_plans/overage_reporter.rb +7 -1
- data/lib/pricing_plans/pay_support.rb +92 -100
- data/lib/pricing_plans/plan.rb +113 -7
- data/lib/pricing_plans/plan_owner.rb +97 -7
- data/lib/pricing_plans/plan_owner_identity.rb +30 -0
- data/lib/pricing_plans/plan_resolver.rb +53 -37
- data/lib/pricing_plans/version.rb +1 -1
- data/lib/pricing_plans.rb +6 -1
- metadata +14 -4
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/.rubocop.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
plugins:
|
|
4
4
|
- rubocop-minitest
|
|
5
5
|
- rubocop-performance
|
|
6
6
|
|
|
@@ -11,6 +11,7 @@ AllCops:
|
|
|
11
11
|
- 'bin/**/*'
|
|
12
12
|
- 'vendor/**/*'
|
|
13
13
|
- 'test/dummy/**/*'
|
|
14
|
+
- 'tmp/**/*'
|
|
14
15
|
- 'db/migrate/**/*' # Generated migrations
|
|
15
16
|
- 'lib/generators/**/templates/**/*' # Generator templates
|
|
16
17
|
|
|
@@ -42,7 +43,7 @@ Style/FrozenStringLiteralComment:
|
|
|
42
43
|
EnforcedStyle: always
|
|
43
44
|
|
|
44
45
|
Style/ClassAndModuleChildren:
|
|
45
|
-
EnforcedStyle:
|
|
46
|
+
EnforcedStyle: nested
|
|
46
47
|
|
|
47
48
|
Style/GuardClause:
|
|
48
49
|
MinBodyLength: 3
|
|
@@ -82,7 +83,7 @@ Metrics/PerceivedComplexity:
|
|
|
82
83
|
Max: 10
|
|
83
84
|
|
|
84
85
|
# Naming
|
|
85
|
-
Naming/
|
|
86
|
+
Naming/PredicatePrefix:
|
|
86
87
|
ForbiddenPrefixes:
|
|
87
88
|
- 'is_'
|
|
88
89
|
AllowedMethods:
|
|
@@ -102,10 +103,6 @@ Minitest/MultipleAssertions:
|
|
|
102
103
|
Minitest/AssertTruthy:
|
|
103
104
|
Enabled: false # Allow assert instead of assert_equal true
|
|
104
105
|
|
|
105
|
-
# Rails-specific (even though we don't have full Rails)
|
|
106
|
-
Style/Rails/HttpStatus:
|
|
107
|
-
Enabled: false
|
|
108
|
-
|
|
109
106
|
# Custom overrides for this gem
|
|
110
107
|
Style/AccessorGrouping:
|
|
111
108
|
Enabled: false # Allow separate attr_reader/attr_writer
|
data/.simplecov
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
# SimpleCov configuration file (auto-loaded before test suite)
|
|
4
4
|
# This keeps test_helper.rb clean and follows best practices
|
|
5
5
|
|
|
6
|
-
SimpleCov.
|
|
6
|
+
SimpleCov.configure do
|
|
7
7
|
# Use SimpleFormatter for terminal-only output (no HTML generation)
|
|
8
8
|
formatter SimpleCov::Formatter::SimpleFormatter
|
|
9
9
|
|
|
10
10
|
# Track coverage for the lib directory (gem source code)
|
|
11
|
-
|
|
11
|
+
skip "/test/"
|
|
12
12
|
|
|
13
13
|
# Track the lib and app directories
|
|
14
|
-
|
|
14
|
+
cover "{lib,app}/**/*.rb"
|
|
15
15
|
|
|
16
16
|
# Enable branch coverage for more detailed metrics
|
|
17
17
|
enable_coverage :branch
|
|
@@ -20,13 +20,14 @@ SimpleCov.start do
|
|
|
20
20
|
minimum_coverage line: 80, branch: 65
|
|
21
21
|
|
|
22
22
|
# Disambiguate parallel test runs
|
|
23
|
-
|
|
23
|
+
test_env_number = ENV.fetch("TEST_ENV_NUMBER", nil)
|
|
24
|
+
command_name "Job #{test_env_number}" if test_env_number
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# Print coverage summary to terminal after tests complete
|
|
27
28
|
SimpleCov.at_exit do
|
|
28
29
|
SimpleCov.result.format!
|
|
29
|
-
puts "\n
|
|
30
|
+
puts "\n#{'=' * 60}"
|
|
30
31
|
puts "COVERAGE SUMMARY"
|
|
31
32
|
puts "=" * 60
|
|
32
33
|
puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
|
data/Appraisals
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
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
|
+
|
|
16
|
+
## [0.6.0] - 2026-08-31
|
|
17
|
+
|
|
18
|
+
**Repricing without app migrations: grandfathering and per-owner feature grants.**
|
|
19
|
+
|
|
20
|
+
- Add the `grandfather` plan DSL: `grandfather :feature, subscribed_before: <time>` declares that owners whose qualifying pricing relationship predates the cutoff keep a feature the plan no longer `allows`. Pure configuration — no columns, no backfills, no rake tasks; the initializer stays the git-versioned record of every pricing change
|
|
21
|
+
- Grandfather eligibility uses the older of the current assignment and same-plan subscription `created_at`. It models a continuous pricing relationship (Pay keeps the same subscription row across price swaps, and updating an assignment keeps that row's age), not unavailable plan-change history; lapse/re-subscribe or clear/reassign resets the corresponding timestamp, while exact materialized cohorts belong in feature grants
|
|
22
|
+
- Add `PricingPlans::FeatureGrant` and the per-owner grants API: `grant_feature!`, `revoke_feature!`, `feature_granted?`, `feature_grants` — individual, auditable exceptions (comps, beta access, sales promises, remediation) that attach to the owner and survive plan changes and cancellation until expiry or revocation; revocation stamps `revoked_at` and keeps the row as history
|
|
23
|
+
- `plan_allows?` (and the `plan_allows_x?` sugar) now honors all three entitlement sources — plan, grandfather, grant — so existing app gates pick everything up with zero changes; `feature_entitlement_source` answers which one applied (`:plan | :grandfather | :grant | nil`)
|
|
24
|
+
- Keep `past_due` Pay subscriptions entitled while the processor retries payment, and prefer a current subscription whose processor price exists in the plan registry when an owner has several
|
|
25
|
+
- Do not let a stale `past_due` status re-entitle a Stripe subscription after an already-scheduled `void` pause becomes effective; future pauses remain entitled through their scheduled date
|
|
26
|
+
- Use Rails' canonical polymorphic owner identity throughout assignments, grants, and admin scopes (including STI), and serialize grant mutations on the owner row so idempotent writes remain race-safe
|
|
27
|
+
- Declaring `grandfather` for a feature the plan still `allows` raises a `ConfigurationError` (one of the two lines is a mistake); unparseable cutoffs raise too, Rails `TimeWithZone` values are accepted, and date-only cutoffs are read as midnight UTC
|
|
28
|
+
- The install generator now creates `pricing_plans_feature_grants`; existing apps add it with the new `rails generate pricing_plans:grants` (plan-level grandfathering needs no table at all — reads degrade silently without it, grant writes raise with instructions)
|
|
29
|
+
- Advance the deprecation horizon to 0.7.0 (the APIs deprecated in 0.5.0 live one more minor)
|
|
30
|
+
- Make grace windows a validated invariant: `grace` defaults only for `:grace_then_block`, must be a positive finite numeric duration of at least one second, and cannot be declared at all (including `nil`/`false`) with `:block_usage`/`:just_warn`. Previously every limit silently carried `grace: 7.days`, and malformed values could make reporting disagree with enforcement
|
|
31
|
+
- `OverageReporter` items now carry `grace_window` (the configured window on the target plan, enforced-only by construction) alongside the runtime `grace_active`/`grace_ends_at`, so downgrade and dunning copy can say "after a N-day grace window" without re-deriving enforcement rules
|
|
32
|
+
- Plan resolution now reads Pay's underlying `payment_processor` association without invoking Pay's auto-provisioning reader, so asking for the current plan can never create a `pay_customers` row; PORO/legacy adapters still use their public reader
|
|
33
|
+
- Add docs/07-repricing.md (the full grandfathering + grants guide), make plan_allows? entitlement-awareness explicit in the model-helpers doc, and bless two long-requested recipes in the docs: reacting to plan changes via Pay lifecycle hooks (#13) and running multiple plan-owner models side by side (#22)
|
|
34
|
+
|
|
1
35
|
## [0.5.0] - 2026-08-31
|
|
2
36
|
|
|
3
37
|
- Add intention-revealing pricing plan override APIs: `override_pricing_plan!`, `clear_pricing_plan_override!`, `pricing_plan_overridden?`, `pricing_plan_override`, and `pricing_plan_override_source`
|
data/README.md
CHANGED
|
@@ -160,7 +160,7 @@ Integrating payment processing (Stripe, `pay`, etc.) is relatively straightforwa
|
|
|
160
160
|
|
|
161
161
|
## Why the models?
|
|
162
162
|
|
|
163
|
-
The `pricing_plans` gem
|
|
163
|
+
The `pricing_plans` gem uses four models: `Assignment`, `EnforcementState`, `Usage`, and `FeatureGrant`. Why are they needed?
|
|
164
164
|
|
|
165
165
|
- `PricingPlans::Assignment` stores explicit pricing plan overrides independently of the billing system. This is useful for gifts, employee access, demos, support interventions, and grandfathered customers.
|
|
166
166
|
- What: A `plan_key` and a required provenance label such as `"admin"`, `"customer_success_gift"`, or `"legacy_import"`. There can be only one override per plan owner.
|
|
@@ -188,6 +188,10 @@ The old `assign_pricing_plan!` and `remove_pricing_plan!` names are deprecated b
|
|
|
188
188
|
- What: `period_start`, `period_end`, and a monotonic `used` counter with a last-used timestamp.
|
|
189
189
|
- How it’s used: On create of the metered model, we increment or upsert the usage for the current window (based on `PeriodCalculator`). Reads power `remaining`, `percent_used`, and warning thresholds.
|
|
190
190
|
|
|
191
|
+
- `PricingPlans::FeatureGrant` stores optional per-owner feature exceptions independently of the current plan.
|
|
192
|
+
- What: A feature key, provenance source, optional note/expiry, and revocation timestamp.
|
|
193
|
+
- How it’s used: Active grants participate in `plan_allows?`; revocation retains the row so the grant/revocation lifecycle remains inspectable. Apps upgrading from before 0.6.0 add this table with `rails generate pricing_plans:grants && rails db:migrate`.
|
|
194
|
+
|
|
191
195
|
## Gem features
|
|
192
196
|
|
|
193
197
|
Enforcing pricing plans is one of those boring plumbing problems that look easy from a distance but get complex when you try to engineer them for production usage. The poor man's implementation of nested ifs shown in the example above only get you so far, you soon start finding edge cases to consider. Here's some of what we've covered in this gem:
|
|
@@ -232,6 +236,65 @@ Notes:
|
|
|
232
236
|
- If you provide a `config.message_builder`, it’s used to customize copy for the `:overage_report` context.
|
|
233
237
|
- This reporter works regardless of whether any controller/model action has been hit; it reads live counts and current period usage.
|
|
234
238
|
|
|
239
|
+
## Changing your pricing: grandfathering and feature grants
|
|
240
|
+
|
|
241
|
+
Sooner or later you'll reprice: a feature that used to be on a cheap plan moves to a higher one. The customers who already pay you should keep what they signed up for — and that should not require new columns, backfills, or rake tasks in your app. (Full guide: [docs/07-repricing.md](/docs/07-repricing.md).)
|
|
242
|
+
|
|
243
|
+
### Grandfathering (declarative, zero state)
|
|
244
|
+
|
|
245
|
+
Remove the feature from the plan's `allows`, and declare the grandfather right next to it:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
plan :indie do
|
|
249
|
+
allows :api_access # :distribution moved to :starter on 2026-08-31
|
|
250
|
+
grandfather :distribution, subscribed_before: "2026-09-01"
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
That's the whole migration. Owners whose qualifying pricing relationship predates the cutoff keep the feature; everyone who arrives later doesn't. `plan_allows?(:distribution)` (and the `plan_allows_distribution?` sugar) just keep working everywhere — the gate code in your app doesn't change at all. Your initializer stays the single, git-versioned record of what changed and when.
|
|
255
|
+
|
|
256
|
+
Semantics, precisely:
|
|
257
|
+
|
|
258
|
+
- **Eligibility time** is the older of the current manual assignment and a current subscription whose processor price maps to the same resolved plan. An old subscription on another plan cannot make a new override eligible.
|
|
259
|
+
- Pay keeps a subscription's original `created_at` when its price is swapped; changing the plan key on an existing manual assignment likewise keeps that assignment row's age. This measures a **continuous pricing relationship**, not the exact date the current plan or price was selected. That is usually the desired grandfathering policy; use grants if you need a frozen, exact historical cohort.
|
|
260
|
+
- Grandfathering rides that continuous relationship: cancel and re-subscribe, or clear and later recreate an assignment, and you re-enter at current pricing. For a promise that must survive anything, use a grant (below).
|
|
261
|
+
- Cutoffs accept a `Time`, `ActiveSupport::TimeWithZone`, `Date`, or `String`; date-only values are read as **midnight UTC**.
|
|
262
|
+
- Declaring `grandfather` for a feature the plan still `allows` raises a configuration error — one of the two lines is a mistake.
|
|
263
|
+
|
|
264
|
+
### Feature grants (per-owner exceptions)
|
|
265
|
+
|
|
266
|
+
For individual exceptions — comps, beta access, sales promises, support remediation, or a grandfather that must survive cancellation — grant the feature to the owner directly:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
org.grant_feature!(:distribution, source: "founder_comp", note: "conference friend")
|
|
270
|
+
org.grant_feature!(:sso, source: "sales", expires_at: 30.days.from_now)
|
|
271
|
+
|
|
272
|
+
org.plan_allows?(:sso) # => true (source-aware predicate below)
|
|
273
|
+
org.feature_entitlement_source(:sso) # => :plan | :grandfather | :grant | nil
|
|
274
|
+
org.feature_granted?(:sso) # => true (active grant row exists)
|
|
275
|
+
|
|
276
|
+
org.revoke_feature!(:sso, note: "eval over") # keeps the row, stamps revoked_at
|
|
277
|
+
org.feature_grants # retained grant/revocation history
|
|
278
|
+
```
|
|
279
|
+
|
|
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
|
+
|
|
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
|
+
|
|
235
298
|
### Override checks
|
|
236
299
|
|
|
237
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.
|
|
@@ -129,12 +129,17 @@ PricingPlans.configure do |config|
|
|
|
129
129
|
end
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
`grace` must be a positive numeric duration of at least one second. Omitting it
|
|
133
|
+
for `:grace_then_block` defaults to `7.days`. Do not pass `grace` (even as
|
|
134
|
+
`nil` or `false`) to `:block_usage` or `:just_warn`; those modes never honor a
|
|
135
|
+
grace window and configuration fails fast instead of storing misleading data.
|
|
136
|
+
|
|
132
137
|
In summary: persistent caps count live rows (per plan owner model). When over the cap:
|
|
133
138
|
- `:just_warn` → validation passes; use controller guard to warn.
|
|
134
139
|
- `:block_usage` → validation fails immediately (uses `error_after_limit` if set).
|
|
135
140
|
- `:grace_then_block` → validation fails once grace is considered “blocked” (we track and switch from grace to blocked).
|
|
136
141
|
|
|
137
|
-
Note: `grace` is only valid with
|
|
142
|
+
Note: `grace` is only valid with `:grace_then_block`. We’ll raise at boot if you declare it with `:block_usage` or `:just_warn`.
|
|
138
143
|
|
|
139
144
|
### Per‑period allowances
|
|
140
145
|
|
data/docs/03-model-helpers.md
CHANGED
|
@@ -16,6 +16,16 @@ end
|
|
|
16
16
|
|
|
17
17
|
By adding the `PricingPlans::PlanOwner` mixin to a model, you automatically get all the features described below.
|
|
18
18
|
|
|
19
|
+
### Multiple plan-owner models (two-sided marketplaces)
|
|
20
|
+
|
|
21
|
+
You can include `PricingPlans::PlanOwner` in more than one model (say, `User` on the consumer side and `Organization` on the company side, each with their own `pay_customer`). Plan resolution is per-owner, so this mostly just works ([#22](https://github.com/rameerez/pricing_plans/issues/22)):
|
|
22
|
+
|
|
23
|
+
- Give each side its own paid plans; a subscription resolves whichever plan matches its Stripe price, regardless of which model holds it.
|
|
24
|
+
- In controllers, point the gem at the right owner per side with a custom plan-owner method (see the [controller helpers](02-controller-helpers.md)) returning `Current.user` or `Current.organization` as appropriate.
|
|
25
|
+
- For pricing pages, tag plans with `meta`data (e.g. `meta audience: "users"` vs `"organizations"`) and filter [`PricingPlans.plans`](01-define-pricing-plans.md#plan-metadata-for-ui-and-presentation) by it, so each side sees only its own ladder.
|
|
26
|
+
|
|
27
|
+
The one real constraint today: there is a single global `default!` plan (and a single `highlighted!`), shared by every owner model — so keep the default plan generic enough for both sides. First-class plan groups (per-owner-type defaults and highlights) are a candidate for a future release.
|
|
28
|
+
|
|
19
29
|
## Link plan limits to your `PlanOwner` model
|
|
20
30
|
|
|
21
31
|
Now you can link any `has_many` relationships in this model to `limits` defined in your `pricing_plans.rb`
|
|
@@ -132,6 +142,12 @@ Of course, there's also dynamic syntactic sugar of the form `plan_allows_<featur
|
|
|
132
142
|
user.plan_allows_api_access?
|
|
133
143
|
```
|
|
134
144
|
|
|
145
|
+
`plan_allows?` is entitlement-aware, not just plan-aware: it also honors [grandfathered features and per-owner grants](07-repricing.md) (since 0.6.0), so it stays the one predicate to gate on even after you reprice. To see *why* an owner is entitled:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
user.feature_entitlement_source(:api_access) # => :plan | :grandfather | :grant | nil
|
|
149
|
+
```
|
|
150
|
+
|
|
135
151
|
## Usage and limits status
|
|
136
152
|
|
|
137
153
|
Checking the current usage with respect to plan limits comes in handy, especially when [building views](/docs/04-views.md). The following methods are useful to build warning / alert snippets, upgrade prompts, usage trackers, etc.
|
|
@@ -30,6 +30,8 @@ Newly generated initializers enable this strict behavior. Intentional default-ti
|
|
|
30
30
|
|
|
31
31
|
As long as a matching `stripe_price` is found in the `pricing_plans.rb` initializer, the gem will know a user subscribed to that Stripe price ID is under the corresponding plan. Essentially, the gem just looks at the current `pay` subscriptions of your user. If a matching price ID is found in the `pricing_plans` configuration file, it enforces the corresponding limits.
|
|
32
32
|
|
|
33
|
+
Plan resolution treats Pay subscriptions as entitlement-bearing while they are active, trialing, in a cancellation grace period, or `past_due`. A failed renewal therefore does not abruptly downgrade a customer while the processor is still retrying payment; canceled and unpaid subscriptions no longer qualify. If an owner has multiple current subscriptions, the gem prefers one whose `processor_plan` matches the configured registry instead of blindly taking the first row.
|
|
34
|
+
|
|
33
35
|
> [!TIP]
|
|
34
36
|
> To make your `pricing_plans` gem config work across environments (production, development, etc.) instead of defining price IDs statically like this in the config:
|
|
35
37
|
>
|
|
@@ -45,6 +47,39 @@ As long as a matching `stripe_price` is found in the `pricing_plans.rb` initiali
|
|
|
45
47
|
>
|
|
46
48
|
> You can come up with similar solutions, like adding that config to a plaintext `.yml` file if you don't want to store this info in the credentials file, but this is the overall idea.
|
|
47
49
|
|
|
50
|
+
### Reacting to plan changes ("repackaging")
|
|
51
|
+
|
|
52
|
+
`pricing_plans` resolves plans lazily — it reads the current Pay subscription when you ask, and never writes anything on plan changes. That's a feature (no sync bugs, no migrations), but it means the gem has no built-in "plan changed" callback to run side effects like disabling excess resources on downgrade or re-enabling them on upgrade.
|
|
53
|
+
|
|
54
|
+
The blessed recipe (worked out in [#13](https://github.com/rameerez/pricing_plans/issues/13)) is to hook Pay's own subscription lifecycle, which is exactly where plan changes become visible:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
# app/models/concerns/pay_extension.rb
|
|
58
|
+
module PayExtension
|
|
59
|
+
extend ActiveSupport::Concern
|
|
60
|
+
|
|
61
|
+
included do
|
|
62
|
+
after_commit :repackage_for_subscription, on: [ :create, :update, :destroy ]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def repackage_for_subscription
|
|
66
|
+
owner = customer&.owner
|
|
67
|
+
return unless owner.is_a?(Organization)
|
|
68
|
+
|
|
69
|
+
RepackageService.new(owner).call # your idempotent per-plan side effects
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# config/initializers/pay.rb
|
|
74
|
+
ActiveSupport.on_load(:pay_subscription) do
|
|
75
|
+
include PayExtension
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Keep the service **idempotent** (safe to run twice) and derive everything from `owner.current_pricing_plan` — that way it also doubles as a backfill job you can run over all owners after changing the rules. Note that if you also use [manual plan overrides](03-model-helpers.md#check-and-explicitly-override-plans), those change plans without touching Pay, so run the same service after calling `override_pricing_plan!` too.
|
|
80
|
+
|
|
81
|
+
Also consider whether you need repackaging at all: for limits, the gem's own [downgrade semantics](../README.md#downgrades-and-overages) (block writes while over-quota, never delete data) often cover it with no code.
|
|
82
|
+
|
|
48
83
|
## `usage_credits` gem
|
|
49
84
|
|
|
50
85
|
In the SaaS world, pricing plans and usage credits are related in so far credits are usually a part of a pricing plan. A plan would give you, say, 100 credits a month along other features, and users would find that information usually documented in the pricing table itself.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Repricing: grandfathering and feature grants
|
|
2
|
+
|
|
3
|
+
Sooner or later you'll change your pricing: a feature that used to live on a cheap plan moves to a higher one. The customers who already pay you should keep what they signed up for — and that should not cost you new columns, backfill migrations, or bypass code in your gates.
|
|
4
|
+
|
|
5
|
+
`pricing_plans` handles both halves of this natively (since 0.6.0):
|
|
6
|
+
|
|
7
|
+
- **Grandfathering** — cohort-level policy, declared in your initializer. Zero database state.
|
|
8
|
+
- **Feature grants** — per-owner exceptions (comps, beta access, sales promises), stored and auditable.
|
|
9
|
+
|
|
10
|
+
Both flow through `plan_allows?` and the `plan_allows_<feature>?` sugar, so every gate you've already written picks them up with no changes.
|
|
11
|
+
|
|
12
|
+
## Grandfathering (one line, zero state)
|
|
13
|
+
|
|
14
|
+
Say `:distribution` used to be on your `:indie` plan and now starts at `:starter`. Remove it from `allows`, and declare the grandfather right next to it:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
plan :indie do
|
|
18
|
+
allows :api_access
|
|
19
|
+
grandfather :distribution, subscribed_before: "2026-09-01"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
plan :starter do
|
|
23
|
+
allows :api_access, :distribution
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
That's the entire repricing migration. Owners whose qualifying pricing relationship predates the cutoff keep `:distribution`; everyone who arrives later sees your upgrade gate. Your initializer stays the single, git-versioned record of what changed and when — future-you can read the whole pricing policy in one file.
|
|
28
|
+
|
|
29
|
+
### The semantics, precisely
|
|
30
|
+
|
|
31
|
+
- **Eligibility time** is the older of the current manual assignment and a current subscription whose processor price maps to the same resolved plan. A subscription on another plan is ignored, so it cannot manufacture grandfather rights for a new override.
|
|
32
|
+
- Pay updates the same subscription row during an in-place price swap, preserving its original `created_at`; changing the plan key on an existing assignment also keeps that row's age. Grandfathering therefore measures a **continuous pricing relationship**, not the exact date that the current plan or price was selected. If your policy needs an exact historical plan-enrollment cohort, materialize that cohort with grants instead.
|
|
33
|
+
- Grandfathering rides that continuous relationship. Cancel and re-subscribe, or clear and later recreate an assignment, and the customer re-enters at current pricing. If you promised someone the feature *forever*, that's a grant (below).
|
|
34
|
+
- `active`, trialing, grace-period, and `past_due` subscriptions remain entitlement-bearing; canceled and unpaid subscriptions do not. This keeps access stable while a processor retries a failed payment.
|
|
35
|
+
- Cutoffs accept a `Time`, `ActiveSupport::TimeWithZone`, `Date`, or `String`. Date-only values are read as **midnight UTC**.
|
|
36
|
+
- Owners on the default (free) plan have no qualifying relationship timestamp and are never grandfathered.
|
|
37
|
+
- Declaring `grandfather` for a feature the plan still `allows` raises a `ConfigurationError` at boot — one of those two lines is a mistake.
|
|
38
|
+
|
|
39
|
+
> [!TIP]
|
|
40
|
+
> Pick a cutoff just **after** your deploy moment (e.g. tomorrow's date), so anyone who signs up while you're shipping the change lands on the generous side of the line.
|
|
41
|
+
|
|
42
|
+
## Feature grants (per-owner exceptions)
|
|
43
|
+
|
|
44
|
+
For individual exceptions, grant the feature to the owner directly:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
org.grant_feature!(:distribution, source: "founder_comp", note: "conference friend")
|
|
48
|
+
org.grant_feature!(:sso, source: "sales", expires_at: 30.days.from_now)
|
|
49
|
+
|
|
50
|
+
org.plan_allows?(:sso) # => true
|
|
51
|
+
org.feature_granted?(:sso) # => true (an active grant row exists)
|
|
52
|
+
|
|
53
|
+
org.revoke_feature!(:sso, note: "eval over")
|
|
54
|
+
org.feature_grants # retained grant/revocation history
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Use grants for:
|
|
58
|
+
|
|
59
|
+
- **Comps** — a friend, a case study, an influencer.
|
|
60
|
+
- **Beta access** — grant a feature to a handful of owners before it's on any plan.
|
|
61
|
+
- **Sales promises** — "you'll get X while you evaluate", with `expires_at`.
|
|
62
|
+
- **Support remediation** — "we broke your week, here's X until renewal".
|
|
63
|
+
- **A grandfather that must survive cancellation** — grants attach to the owner, not the plan, so they persist through plan changes and cancellation until expiry or revocation.
|
|
64
|
+
|
|
65
|
+
Grants are **never deleted** by the API: revoking stamps `revoked_at` and keeps the row, preserving each grant/revocation lifecycle. Updating an active grant changes that row; this is not an event-by-event audit log of every field edit.
|
|
66
|
+
|
|
67
|
+
Granting the same feature twice updates the active grant instead of stacking; revoking and granting again creates a fresh row (history preserved).
|
|
68
|
+
|
|
69
|
+
### The table
|
|
70
|
+
|
|
71
|
+
Fresh installs get the `pricing_plans_feature_grants` table from the install generator. If you installed pricing_plans before 0.6.0, add it with:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
rails generate pricing_plans:grants && rails db:migrate
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Declarative grandfathering needs **no table at all**. Without the table, grant *reads* silently report no grants, and grant *writes* raise with the command above.
|
|
78
|
+
|
|
79
|
+
## Which one applied?
|
|
80
|
+
|
|
81
|
+
When you're debugging or answering a support ticket, ask the owner directly:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
org.feature_entitlement_source(:distribution)
|
|
85
|
+
# => :plan (the resolved plan allows it)
|
|
86
|
+
# => :grandfather (qualifying pricing relationship predates the cutoff)
|
|
87
|
+
# => :grant (an active per-owner grant)
|
|
88
|
+
# => nil (not entitled)
|
|
89
|
+
|
|
90
|
+
org.pricing_relationship_started_at
|
|
91
|
+
# => qualifying same-plan subscription / assignment timestamp (or nil)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## A real-world example
|
|
95
|
+
|
|
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
|
+
|
|
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.
|