pricing_plans 0.4.1 → 0.6.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: 1a22c4cf12c06ba9c2e7d649811c286044ae8ff5f9586bea223584fa10b054e4
4
- data.tar.gz: 55ee30afed8af0fd32018ca6d706ab0efe4cd745346fc24787d90ddbcf815071
3
+ metadata.gz: b6b80d22fc74b3afafd8f850f0bbe401c9af56d9dad8102233c48e4e332f1b62
4
+ data.tar.gz: a03538e62a477cb3ba9f90df6e27de51a48d7b348f2aef96dd7cca0dd997bf88
5
5
  SHA512:
6
- metadata.gz: bf40f8507ad67ca84c0e46e390db6fdb65e13ea247f5f1a12446c88ec6eaff59d0b519f2951538a59858f2b4222c4257112c5f13d4697a78fc84124e6c713782
7
- data.tar.gz: 2a3906414a684300257e1b20b1f4b3cbd278ef7631c7a4f7c675f006483cb107145c323e61072e0fe2fe91976ca140b86dc617d3a14cc1495a010cf8c897c19a
6
+ metadata.gz: 605f1beb4fdc85b9b8b26b2e8da96fb620f9b8bd28ecadf4688d5b00cf002aaa979155abeccef100f50e3a9b9932095506b691e0bbd84f4727f0cebdaaba40bd
7
+ data.tar.gz: 3b8ecc71305850c3760d3a96ee530c2069a099806733223280b834cd368fbe9773d8483f5a7fa02744fd0704206d6a142a0933d8837133c28ca85a93fc0d1860
data/.rubocop.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require:
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: compact
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/PredicateName:
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
@@ -134,4 +131,4 @@ Style/GlobalVars:
134
131
 
135
132
  # Database-related
136
133
  Style/NumericLiterals:
137
- Enabled: false # Allow raw numbers in database IDs/amounts
134
+ Enabled: false # Allow raw numbers in database IDs/amounts
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.start do
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
- add_filter "/test/"
11
+ skip "/test/"
12
12
 
13
13
  # Track the lib and app directories
14
- track_files "{lib,app}/**/*.rb"
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
- command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
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" + "=" * 60
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
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ appraise "rails-7.1" do
4
+ gem "rails", "~> 7.1.0"
5
+ end
6
+
3
7
  appraise "rails-7.2" do
4
- gem "rails", "~> 7.2.3"
8
+ gem "rails", "~> 7.2.3", ">= 7.2.3.2"
5
9
  end
6
10
 
7
11
  appraise "rails-8.1" do
data/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ ## [0.6.0] - 2026-08-31
2
+
3
+ **Repricing without app migrations: grandfathering and per-owner feature grants.**
4
+
5
+ - 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
6
+ - 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
7
+ - 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
8
+ - `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`)
9
+ - 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
10
+ - 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
11
+ - 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
12
+ - 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
13
+ - 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)
14
+ - Advance the deprecation horizon to 0.7.0 (the APIs deprecated in 0.5.0 live one more minor)
15
+ - 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
16
+ - `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
17
+ - 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
18
+ - 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)
19
+
20
+ ## [0.5.0] - 2026-08-31
21
+
22
+ - 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`
23
+ - Require explicit `source:` provenance when creating overrides through the new API
24
+ - Remove the implicit `"manual"` source default from newly generated assignment tables; legacy APIs still supply it explicitly for compatibility
25
+ - Add matching explicit `PlanResolver` and `Assignment` entry points, plus symmetric `override_pricing_plan_for!` and `clear_pricing_plan_override_for!` helpers on `Limitable` models, for lower-level use cases
26
+ - Add override-oriented provenance readers to `PlanResolution` while preserving its assignment-oriented compatibility readers
27
+ - Deprecate the ambiguous `assign_pricing_plan!`, `remove_pricing_plan!`, `assign_plan_manually!`, `remove_manual_assignment!`, `assign_plan_to`, and `remove_assignment_for` APIs through a gem-specific Active Support deprecator
28
+ - Guard legacy assignments of the configured default plan with configurable `:allow`, `:warn`, or `:raise` behavior; upgraded applications default to `:warn`, while newly generated initializers choose the safer `:raise`
29
+ - Clarify that ordinary free/default accounts should remain unassigned and that intentional default-tier pinning remains supported through the explicit override API
30
+
1
31
  ## [0.4.1] - 2026-08-24
2
32
 
3
33
  - **Never 500 a pricing page when Stripe is unreachable**: `Plan#currency_symbol` was the one Stripe lookup without a rescue — a Stripe outage, rate limit, or missing API key (any test/CI environment) raised straight through the pricing page. It now degrades to `default_currency_symbol` like every other presentation method (#24)
data/README.md CHANGED
@@ -160,12 +160,25 @@ Integrating payment processing (Stripe, `pay`, etc.) is relatively straightforwa
160
160
 
161
161
  ## Why the models?
162
162
 
163
- The `pricing_plans` gem needs three new models in the schema in order to work: `Assignment`, `EnforcementState`, and `Usage`. Why are they needed?
163
+ The `pricing_plans` gem uses four models: `Assignment`, `EnforcementState`, `Usage`, and `FeatureGrant`. Why are they needed?
164
164
 
165
- - `PricingPlans::Assignment` allow manual plan overrides independent of billing system (or before you wire up Stripe/Pay). Great for admin toggles, trials, demos.
166
- - What: The arbitrary `plan_key` and a `source` label (default "manual"). Unique per plan_owner.
167
- - How it's used: `PlanResolver` checks manual assignment → Pay → default plan. Manual assignments (admin overrides) take precedence over subscription-based plans. You can call `assign_pricing_plan!` and `remove_pricing_plan!` on the plan_owner.
168
- - Provenance helpers: `current_pricing_plan_source` tells you whether the effective plan came from `:assignment`, `:subscription`, or `:default`, and `current_pricing_plan_resolution` exposes the assignment and current subscription objects when you need both entitlement and billing context.
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
+ - 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.
167
+ - How it's used: `PlanResolver` checks explicit override → Pay subscription configured default. Every persisted override takes precedence over subscription-based plans, including an override whose key happens to equal the configured default.
168
+ - Intention-revealing API: call `plan_owner.override_pricing_plan!(:pro, source: "admin")` to create or update an override, and `plan_owner.clear_pricing_plan_override!` to resume normal subscription/default resolution.
169
+ - Provenance helpers: `pricing_plan_overridden?`, `pricing_plan_override`, and `pricing_plan_override_source` expose the override directly. `current_pricing_plan_resolution` preserves both entitlement and billing context when an override and a subscription coexist.
170
+
171
+ An ordinary free/default account needs no assignment row:
172
+
173
+ ```ruby
174
+ # Intentional exception: pin this organization to Pro independently of billing.
175
+ organization.override_pricing_plan!(:pro, source: "customer_success_gift")
176
+
177
+ # Resume automatic Pay subscription / configured default resolution.
178
+ organization.clear_pricing_plan_override!
179
+ ```
180
+
181
+ The old `assign_pricing_plan!` and `remove_pricing_plan!` names are deprecated because they hide the override semantics. See [Check and explicitly override plans](docs/03-model-helpers.md#check-and-explicitly-override-plans) for migration and strict-mode guidance.
169
182
 
170
183
  - `PricingPlans::EnforcementState` tracks per-plan_owner per-limit enforcement state for persistent caps and per-period allowances (grace/warnings/block state) in a race-safe way.
171
184
  - What: `exceeded_at`, `blocked_at`, last warning info, and a small JSON `data` column where we persist plan-derived parameters like grace period seconds.
@@ -175,6 +188,10 @@ The `pricing_plans` gem needs three new models in the schema in order to work: `
175
188
  - What: `period_start`, `period_end`, and a monotonic `used` counter with a last-used timestamp.
176
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.
177
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
+
178
195
  ## Gem features
179
196
 
180
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:
@@ -196,7 +213,7 @@ Enforcing pricing plans is one of those boring plumbing problems that look easy
196
213
 
197
214
  ## Downgrades and overages
198
215
 
199
- When a customer moves to a lower plan (via Stripe/Pay or manual assignment), the new plan’s limits start applying immediately. Existing resources are never auto‑deleted by the gem; instead:
216
+ When a customer moves to a lower plan (via Stripe/Pay or an explicit override), the new plan’s limits start applying immediately. Existing resources are never auto‑deleted by the gem; instead:
200
217
 
201
218
  - **Persistent caps** (e.g., `:projects, to: 3`): We count live rows. If the account is now over the new cap, creations will be blocked (or put into grace/warn depending on `after_limit`). Users must remediate by deleting/archiving until under cap.
202
219
  -
@@ -219,6 +236,49 @@ Notes:
219
236
  - If you provide a `config.message_builder`, it’s used to customize copy for the `:overage_report` context.
220
237
  - This reporter works regardless of whether any controller/model action has been hit; it reads live counts and current period usage.
221
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
+
222
282
  ### Override checks
223
283
 
224
284
  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 blocking behaviors. We’ll raise at boot if you set `grace` with `:just_warn`.
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
 
@@ -188,7 +193,7 @@ Callback timing:
188
193
 
189
194
  ```ruby
190
195
  # pro allows 3 custom models per month
191
- PricingPlans::Assignment.assign_plan_to(org, :pro)
196
+ org.override_pricing_plan!(:pro, source: "test_setup")
192
197
 
193
198
  travel_to(Time.parse("2025-01-15 12:00:00 UTC")) do
194
199
  3.times { org.custom_models.create!(name: "Model") }
@@ -457,7 +462,7 @@ end
457
462
  **Important notes:**
458
463
  - Hidden plans can be the `default!` plan (common pattern for "unsubscribed" users)
459
464
  - Hidden plans **cannot** be the `highlighted!` plan (validation error - highlighted plans must be visible)
460
- - Users can still be on hidden plans (via Pay subscription, manual assignment, or default)
465
+ - Users can still be on hidden plans (via Pay subscription, an explicit pricing plan override, or the configured default)
461
466
  - Internal APIs (`Registry.plans`, `PlanResolver`) can still access hidden plans
462
467
  - Pay gem can still resolve subscriptions to hidden plans (useful for grandfathered customers)
463
468
 
@@ -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.
@@ -290,18 +306,64 @@ You can also use the top-level equivalents if you prefer: `PricingPlans.severity
290
306
 
291
307
  ## Other helpers and methods
292
308
 
293
- ### Check and override plans
309
+ ### Check and explicitly override plans
310
+
311
+ An override is an intentional exception to normal plan resolution. It is useful for gifts, employee accounts, support interventions, demos, and grandfathering. The method names deliberately say **override** because an override always takes precedence over both Pay subscriptions and the configured default:
312
+
313
+ ```ruby
314
+ user.current_pricing_plan # => PricingPlans::Plan
315
+ user.current_pricing_plan_source # => :assignment, :subscription, :default
316
+ user.current_pricing_plan_resolution # => PricingPlans::PlanResolution
317
+
318
+ user.override_pricing_plan!(:pro, source: "admin") # create/update an explicit override
319
+ user.pricing_plan_overridden? # => true
320
+ user.pricing_plan_override # => PricingPlans::Assignment
321
+ user.pricing_plan_override_source # => "admin"
322
+ user.clear_pricing_plan_override! # resume subscription/default resolution
323
+ ```
324
+
325
+ > [!WARNING]
326
+ > Explicitly overriding to the configured default plan is still an override. For
327
+ > example, `user.override_pricing_plan!(:free, source: "admin_downgrade")`
328
+ > creates an assignment row and continues to take precedence over future Pay
329
+ > subscriptions. That behavior supports intentional default-tier pinning. For an
330
+ > ordinary free/default account, create no assignment. If an override already
331
+ > exists, call `user.clear_pricing_plan_override!` to resume normal resolution.
332
+
333
+ The `source:` keyword is required by the explicit API. Use a stable, auditable description of why the exception exists—such as `"admin"`, `"customer_success_gift"`, `"employee_access"`, or `"legacy_import"`. The source is provenance; it does not alter precedence. Every override wins regardless of its source label.
334
+
335
+ Newly generated assignment tables do not have a database default for `source`, so direct model writes must also state their provenance. Applications installed with an older gem version may still have the historical `"manual"` database default; the explicit public API does not rely on it.
336
+
337
+ #### Migrating from the old assignment API
338
+
339
+ `assign_pricing_plan!` and `remove_pricing_plan!` are deprecated because their names do not reveal that they create and clear persistent overrides. Replace them directly:
294
340
 
295
- You can also check and override the current pricing plan for any user, which comes handy as an admin:
296
341
  ```ruby
297
- user.current_pricing_plan # => PricingPlans::Plan
298
- user.current_pricing_plan_source # => :assignment, :subscription, :default
299
- user.current_pricing_plan_resolution # => PricingPlans::PlanResolution
300
- user.assign_pricing_plan!(:pro) # manual assignment override
301
- user.remove_pricing_plan! # remove manual override (fallback to default)
342
+ # Before: ambiguous and source defaulted silently to "manual"
343
+ user.assign_pricing_plan!(:pro)
344
+ user.remove_pricing_plan!
345
+
346
+ # After: intent and provenance are explicit
347
+ user.override_pricing_plan!(:pro, source: "admin")
348
+ user.clear_pricing_plan_override!
302
349
  ```
303
350
 
304
- **Performance note:** Each call to `current_pricing_plan`, `current_pricing_plan_source`, or `current_pricing_plan_resolution` performs a fresh database lookup. If you need both the plan and its provenance, call `current_pricing_plan_resolution` once and read both values from that object this avoids duplicate queries.
351
+ Legacy calls continue to work during the deprecation window and emit a gem-specific Active Support deprecation warning. Assigning the configured default through a legacy API receives an additional guard because it is usually accidental. Its behavior is configurable:
352
+
353
+ ```ruby
354
+ PricingPlans.configure do |config|
355
+ # :warn — default for upgraded applications; preserve behavior and warn
356
+ # :raise — recommended; reject legacy default-plan assignments before writing
357
+ # :allow — migration escape hatch; emit only the general deprecation warning
358
+ config.legacy_default_plan_assignment_behavior = :raise
359
+ end
360
+ ```
361
+
362
+ The strict `:raise` behavior raises `PricingPlans::LegacyDefaultPlanAssignmentError` and writes no assignment. It never blocks the explicit `override_pricing_plan!` API, even when the target is the default plan, because that method already states the caller's intent. Newly generated initializers use `:raise`.
363
+
364
+ The low-level legacy methods—`PricingPlans::PlanResolver.assign_plan_manually!`, `PricingPlans::PlanResolver.remove_manual_assignment!`, `PricingPlans::Assignment.assign_plan_to`, and `PricingPlans::Assignment.remove_assignment_for`—are deprecated under the same policy. Prefer `PlanResolver.override_pricing_plan_for!`, `PlanResolver.clear_pricing_plan_override_for!`, `Assignment.create_or_update_pricing_plan_override_for!`, and `Assignment.clear_pricing_plan_override_for!` when a lower-level API is genuinely necessary. Models that include `PricingPlans::Limitable` also expose the symmetric class-level pair `override_pricing_plan_for!` and `clear_pricing_plan_override_for!` for compatibility with their previous owner-aware assignment helper.
365
+
366
+ **Performance note:** Each plan/provenance helper performs a fresh database lookup. If you need both the effective plan and its provenance, call `current_pricing_plan_resolution` once and read both from that object.
305
367
 
306
368
  If you need the full provenance, use the resolution object:
307
369
 
@@ -309,13 +371,18 @@ If you need the full provenance, use the resolution object:
309
371
  resolution = user.current_pricing_plan_resolution
310
372
 
311
373
  resolution.plan.key # => :enterprise
312
- resolution.source # => :assignment
313
- resolution.assignment # => PricingPlans::Assignment | nil
314
- resolution.assignment_source # => "admin" | "manual" | nil
374
+ resolution.source # => :assignment (compatibility value)
375
+ resolution.pricing_plan_overridden? # => true
376
+ resolution.pricing_plan_override # => PricingPlans::Assignment | nil
377
+ resolution.pricing_plan_override_source # => "admin" | "legacy_import" | nil
315
378
  resolution.subscription # => Pay subscription | nil
379
+
380
+ # Storage-oriented compatibility readers remain available:
381
+ resolution.assignment # => same object as pricing_plan_override
382
+ resolution.assignment_source # => same value as pricing_plan_override_source
316
383
  ```
317
384
 
318
- This distinction matters: the **effective pricing plan** is what controls entitlements and limits inside your app. The **Pay/Stripe subscription state** is billing-facing. A manual assignment may intentionally override the subscription-backed plan while still leaving the underlying subscription present for billing operations.
385
+ This distinction matters: the **effective pricing plan** controls entitlements and limits inside your app, while the **Pay/Stripe subscription state** is billing-facing. An explicit override may intentionally control entitlements while the underlying subscription remains available for billing operations. Clearing the override does not cancel or modify billing; it simply lets subscription/default resolution take control again.
319
386
 
320
387
  **Edge case:** `source` can be `:default` even when `subscription` is non-nil. This happens when a Pay subscription exists but its `processor_plan` (Stripe price ID) doesn't map to any plan in your registry. The subscription is preserved for billing context, but the effective plan falls back to your configured default.
321
388
 
@@ -14,10 +14,24 @@ There's nothing to do on your end to make `pricing_plans` work with `pay`!
14
14
 
15
15
  As long as your `pricing_plans` config (`config/initializers/pricing_plans.rb`) contains a plan with the correct `stripe_price` ID, whenever a subscription to that Stripe price ID is found through the `pay` gem, `pricing_plans` will understand the user is subscribed to that plan automatically, and will start enforcing the corresponding limits.
16
16
 
17
- The way `pricing_plans` works doesn't require any data migration, or callback setup, or any manual action. You don't need to call `assign_pricing_plan!` at all at any point, unless you're trying to something like overriding a plan, gifting users access to plans without any payment, or things like that.
17
+ The way `pricing_plans` works doesn't require any data migration, callback setup, or assignment during signup. Do not call an override API for ordinary plan selection or after a successful Pay checkout: Pay subscriptions are discovered automatically.
18
+
19
+ Use `plan_owner.override_pricing_plan!(:pro, source: "customer_success_gift")` only when you deliberately want an exception that wins over billing. In particular, do not override to the default/free plan during ordinary signup or free-plan selection. Leave the owner without an override—or call `plan_owner.clear_pricing_plan_override!`—to use Pay/default resolution normally.
20
+
21
+ The deprecated `assign_pricing_plan!` API now warns, and upgraded applications can turn accidental legacy default-plan assignments into errors:
22
+
23
+ ```ruby
24
+ PricingPlans.configure do |config|
25
+ config.legacy_default_plan_assignment_behavior = :raise
26
+ end
27
+ ```
28
+
29
+ Newly generated initializers enable this strict behavior. Intentional default-tier pinning remains available through the self-documenting explicit API: `plan_owner.override_pricing_plan!(:free, source: "admin_downgrade")`.
18
30
 
19
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.
20
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
+
21
35
  > [!TIP]
22
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:
23
37
  >
@@ -33,6 +47,39 @@ As long as a matching `stripe_price` is found in the `pricing_plans.rb` initiali
33
47
  >
34
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.
35
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
+
36
83
  ## `usage_credits` gem
37
84
 
38
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,98 @@
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.
@@ -0,0 +1,26 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 7.1.0"
7
+
8
+ group :development do
9
+ gem "irb"
10
+ gem "rubocop", "~> 1.0"
11
+ gem "rubocop-minitest", "~> 0.35"
12
+ gem "rubocop-performance", "~> 1.0"
13
+ end
14
+
15
+ group :development, :test do
16
+ gem "appraisal"
17
+ gem "minitest", ">= 5.16", "< 7"
18
+ gem "minitest-mock"
19
+ gem "rack-test"
20
+ gem "railties", ">= 7.1", "< 9"
21
+ gem "sqlite3", ">= 2.1"
22
+ gem "ostruct"
23
+ gem "simplecov", require: false
24
+ end
25
+
26
+ gemspec path: "../"