pricing_plans 0.4.1 → 0.5.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: 8003fe05eaafc824f16d2c6dd72f067c04fd9417f066a1a9e9197f42d30518e0
4
+ data.tar.gz: 512a6cc888ce5261ecd37fb7b59e9bd5c16c3571d754154b0ee0e30c385e9c36
5
5
  SHA512:
6
- metadata.gz: bf40f8507ad67ca84c0e46e390db6fdb65e13ea247f5f1a12446c88ec6eaff59d0b519f2951538a59858f2b4222c4257112c5f13d4697a78fc84124e6c713782
7
- data.tar.gz: 2a3906414a684300257e1b20b1f4b3cbd278ef7631c7a4f7c675f006483cb107145c323e61072e0fe2fe91976ca140b86dc617d3a14cc1495a010cf8c897c19a
6
+ metadata.gz: 617a80ea706ea417eb1f8ad8a8676c9690d852a66b454793ba47efe2bb4d112873b13f8a7e7fec7e6e9c9ed7833ae3d36f834be519f9b90bbc4ef18cac6de6d9
7
+ data.tar.gz: 6e86e014b7a3652a48725677819c324fedf23f924db740f30a2f2cf05a3f138673edf8b9d2d95a8c9fcd915e75438613b335fefad3867c1be0fb10389df71353
data/.rubocop.yml CHANGED
@@ -134,4 +134,4 @@ Style/GlobalVars:
134
134
 
135
135
  # Database-related
136
136
  Style/NumericLiterals:
137
- Enabled: false # Allow raw numbers in database IDs/amounts
137
+ Enabled: false # Allow raw numbers in database IDs/amounts
data/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## [0.5.0] - 2026-08-31
2
+
3
+ - 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`
4
+ - Require explicit `source:` provenance when creating overrides through the new API
5
+ - Remove the implicit `"manual"` source default from newly generated assignment tables; legacy APIs still supply it explicitly for compatibility
6
+ - 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
7
+ - Add override-oriented provenance readers to `PlanResolution` while preserving its assignment-oriented compatibility readers
8
+ - 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
9
+ - 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`
10
+ - Clarify that ordinary free/default accounts should remain unassigned and that intentional default-tier pinning remains supported through the explicit override API
11
+
1
12
  ## [0.4.1] - 2026-08-24
2
13
 
3
14
  - **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
@@ -162,10 +162,23 @@ Integrating payment processing (Stripe, `pay`, etc.) is relatively straightforwa
162
162
 
163
163
  The `pricing_plans` gem needs three new models in the schema in order to work: `Assignment`, `EnforcementState`, and `Usage`. 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.
@@ -196,7 +209,7 @@ Enforcing pricing plans is one of those boring plumbing problems that look easy
196
209
 
197
210
  ## Downgrades and overages
198
211
 
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:
212
+ 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
213
 
201
214
  - **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
215
  -
@@ -188,7 +188,7 @@ Callback timing:
188
188
 
189
189
  ```ruby
190
190
  # pro allows 3 custom models per month
191
- PricingPlans::Assignment.assign_plan_to(org, :pro)
191
+ org.override_pricing_plan!(:pro, source: "test_setup")
192
192
 
193
193
  travel_to(Time.parse("2025-01-15 12:00:00 UTC")) do
194
194
  3.times { org.custom_models.create!(name: "Model") }
@@ -457,7 +457,7 @@ end
457
457
  **Important notes:**
458
458
  - Hidden plans can be the `default!` plan (common pattern for "unsubscribed" users)
459
459
  - 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)
460
+ - Users can still be on hidden plans (via Pay subscription, an explicit pricing plan override, or the configured default)
461
461
  - Internal APIs (`Registry.plans`, `PlanResolver`) can still access hidden plans
462
462
  - Pay gem can still resolve subscriptions to hidden plans (useful for grandfathered customers)
463
463
 
@@ -290,18 +290,64 @@ You can also use the top-level equivalents if you prefer: `PricingPlans.severity
290
290
 
291
291
  ## Other helpers and methods
292
292
 
293
- ### Check and override plans
293
+ ### Check and explicitly override plans
294
+
295
+ 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:
296
+
297
+ ```ruby
298
+ user.current_pricing_plan # => PricingPlans::Plan
299
+ user.current_pricing_plan_source # => :assignment, :subscription, :default
300
+ user.current_pricing_plan_resolution # => PricingPlans::PlanResolution
301
+
302
+ user.override_pricing_plan!(:pro, source: "admin") # create/update an explicit override
303
+ user.pricing_plan_overridden? # => true
304
+ user.pricing_plan_override # => PricingPlans::Assignment
305
+ user.pricing_plan_override_source # => "admin"
306
+ user.clear_pricing_plan_override! # resume subscription/default resolution
307
+ ```
308
+
309
+ > [!WARNING]
310
+ > Explicitly overriding to the configured default plan is still an override. For
311
+ > example, `user.override_pricing_plan!(:free, source: "admin_downgrade")`
312
+ > creates an assignment row and continues to take precedence over future Pay
313
+ > subscriptions. That behavior supports intentional default-tier pinning. For an
314
+ > ordinary free/default account, create no assignment. If an override already
315
+ > exists, call `user.clear_pricing_plan_override!` to resume normal resolution.
316
+
317
+ 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.
318
+
319
+ 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.
320
+
321
+ #### Migrating from the old assignment API
322
+
323
+ `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
324
 
295
- You can also check and override the current pricing plan for any user, which comes handy as an admin:
296
325
  ```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)
326
+ # Before: ambiguous and source defaulted silently to "manual"
327
+ user.assign_pricing_plan!(:pro)
328
+ user.remove_pricing_plan!
329
+
330
+ # After: intent and provenance are explicit
331
+ user.override_pricing_plan!(:pro, source: "admin")
332
+ user.clear_pricing_plan_override!
302
333
  ```
303
334
 
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.
335
+ 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:
336
+
337
+ ```ruby
338
+ PricingPlans.configure do |config|
339
+ # :warn — default for upgraded applications; preserve behavior and warn
340
+ # :raise — recommended; reject legacy default-plan assignments before writing
341
+ # :allow — migration escape hatch; emit only the general deprecation warning
342
+ config.legacy_default_plan_assignment_behavior = :raise
343
+ end
344
+ ```
345
+
346
+ 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`.
347
+
348
+ 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.
349
+
350
+ **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
351
 
306
352
  If you need the full provenance, use the resolution object:
307
353
 
@@ -309,13 +355,18 @@ If you need the full provenance, use the resolution object:
309
355
  resolution = user.current_pricing_plan_resolution
310
356
 
311
357
  resolution.plan.key # => :enterprise
312
- resolution.source # => :assignment
313
- resolution.assignment # => PricingPlans::Assignment | nil
314
- resolution.assignment_source # => "admin" | "manual" | nil
358
+ resolution.source # => :assignment (compatibility value)
359
+ resolution.pricing_plan_overridden? # => true
360
+ resolution.pricing_plan_override # => PricingPlans::Assignment | nil
361
+ resolution.pricing_plan_override_source # => "admin" | "legacy_import" | nil
315
362
  resolution.subscription # => Pay subscription | nil
363
+
364
+ # Storage-oriented compatibility readers remain available:
365
+ resolution.assignment # => same object as pricing_plan_override
366
+ resolution.assignment_source # => same value as pricing_plan_override_source
316
367
  ```
317
368
 
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.
369
+ 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
370
 
320
371
  **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
372
 
@@ -14,7 +14,19 @@ 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
 
@@ -57,7 +57,7 @@ class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
57
57
  create_table :pricing_plans_assignments, id: primary_key_type do |t|
58
58
  t.references :plan_owner, polymorphic: true, null: false, type: foreign_key_type
59
59
  t.string :plan_key, null: false
60
- t.string :source, null: false, default: "manual"
60
+ t.string :source, null: false
61
61
 
62
62
  t.timestamps
63
63
  end
@@ -88,4 +88,3 @@ class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
88
88
  end
89
89
  end
90
90
 
91
-
@@ -36,6 +36,11 @@ PricingPlans.configure do |config|
36
36
  allows :api_access, :premium_features
37
37
  end
38
38
 
39
+ # Reject the deprecated, ambiguous assignment API if it is used to assign
40
+ # the configured default plan. Use `override_pricing_plan!(..., source: ...)`
41
+ # for deliberate exceptions and no assignment at all for ordinary defaults.
42
+ config.legacy_default_plan_assignment_behavior = :raise
43
+
39
44
 
40
45
  # Optional settings
41
46
 
@@ -6,7 +6,18 @@ module PricingPlans
6
6
  class Configuration
7
7
  include DSL
8
8
 
9
+ LEGACY_DEFAULT_PLAN_ASSIGNMENT_BEHAVIORS = [:allow, :warn, :raise].freeze
10
+
11
+ def self.legacy_default_plan_assignment_behaviors_description
12
+ descriptions = LEGACY_DEFAULT_PLAN_ASSIGNMENT_BEHAVIORS.map(&:inspect)
13
+ "#{descriptions[0..-2].join(', ')}, or #{descriptions.last}"
14
+ end
15
+
9
16
  attr_accessor :default_plan, :highlighted_plan, :period_cycle
17
+ # Controls what the deprecated assignment APIs do when they are asked to
18
+ # assign the configured default plan. Explicit override APIs are never
19
+ # affected because their names already communicate the caller's intent.
20
+ attr_accessor :legacy_default_plan_assignment_behavior
10
21
  # Optional ergonomics
11
22
  attr_accessor :default_cta_text, :default_cta_url
12
23
  # Debug mode - set to true to enable debug output
@@ -57,6 +68,7 @@ module PricingPlans
57
68
  @default_plan = nil
58
69
  @highlighted_plan = nil
59
70
  @period_cycle = :billing_cycle
71
+ @legacy_default_plan_assignment_behavior = :warn
60
72
  @default_cta_text = nil
61
73
  @default_cta_url = nil
62
74
  @message_builder = nil
@@ -151,6 +163,7 @@ module PricingPlans
151
163
  validate_required_settings!
152
164
  validate_plan_references!
153
165
  validate_dsl_markers!
166
+ validate_legacy_default_plan_assignment_behavior!
154
167
  validate_plans!
155
168
  end
156
169
  def select_defaults_from_dsl!
@@ -205,5 +218,13 @@ module PricingPlans
205
218
  def validate_plans!
206
219
  @plans.each_value(&:validate!)
207
220
  end
221
+
222
+ def validate_legacy_default_plan_assignment_behavior!
223
+ return if LEGACY_DEFAULT_PLAN_ASSIGNMENT_BEHAVIORS.include?(@legacy_default_plan_assignment_behavior)
224
+
225
+ raise PricingPlans::ConfigurationError,
226
+ "legacy_default_plan_assignment_behavior must be " \
227
+ "#{self.class.legacy_default_plan_assignment_behaviors_description}"
228
+ end
208
229
  end
209
230
  end
@@ -4,6 +4,10 @@ module PricingPlans
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace PricingPlans
6
6
 
7
+ initializer "pricing_plans.deprecator" do |app|
8
+ app.deprecators[:pricing_plans] = PricingPlans.deprecator if app.respond_to?(:deprecators)
9
+ end
10
+
7
11
  initializer "pricing_plans.active_record" do
8
12
  ActiveSupport.on_load(:active_record) do
9
13
  # Make models available
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ # Compatibility boundary for assignment-era APIs whose names do not make it
5
+ # clear that every persisted assignment is a pricing plan override.
6
+ #
7
+ # Keep all warnings and the default-plan safety policy here so every legacy
8
+ # entry point behaves identically. New code should bypass this class and use
9
+ # the explicit override APIs.
10
+ class LegacyPlanAssignmentApi
11
+ class << self
12
+ def create_or_update_override!(plan_owner, plan_key, source:, called_method_name:)
13
+ handle_deprecated_assignment_call!(plan_key, called_method_name)
14
+
15
+ Assignment.create_or_update_pricing_plan_override_for!(
16
+ plan_owner,
17
+ plan_key,
18
+ source: source
19
+ )
20
+ end
21
+
22
+ def clear_override!(plan_owner, called_method_name:)
23
+ PricingPlans.deprecator.warn(
24
+ "`#{called_method_name}` is deprecated because its name does not make clear " \
25
+ "that it only clears a persistent pricing plan override. " \
26
+ "Use `clear_pricing_plan_override!` instead."
27
+ )
28
+
29
+ Assignment.clear_pricing_plan_override_for!(plan_owner)
30
+ end
31
+
32
+ private
33
+
34
+ def handle_deprecated_assignment_call!(plan_key, called_method_name)
35
+ if configured_default_plan_key?(plan_key)
36
+ handle_deprecated_default_plan_assignment!(plan_key, called_method_name)
37
+ else
38
+ warn_about_deprecated_assignment_api(called_method_name)
39
+ end
40
+ end
41
+
42
+ def configured_default_plan_key?(plan_key)
43
+ default_plan_key = Registry.default_plan&.key
44
+ default_plan_key && plan_key.to_s == default_plan_key.to_s
45
+ end
46
+
47
+ def handle_deprecated_default_plan_assignment!(plan_key, called_method_name)
48
+ case PricingPlans.configuration.legacy_default_plan_assignment_behavior
49
+ when :allow
50
+ warn_about_deprecated_assignment_api(called_method_name)
51
+ when :warn
52
+ PricingPlans.deprecator.warn(default_plan_assignment_message(plan_key, called_method_name))
53
+ when :raise
54
+ raise LegacyDefaultPlanAssignmentError.new(
55
+ default_plan_assignment_message(plan_key, called_method_name),
56
+ configured_default_plan_key: plan_key,
57
+ legacy_assignment_method_name: called_method_name
58
+ )
59
+ else
60
+ raise ConfigurationError,
61
+ "legacy_default_plan_assignment_behavior must be " \
62
+ "#{Configuration.legacy_default_plan_assignment_behaviors_description}"
63
+ end
64
+ end
65
+
66
+ def warn_about_deprecated_assignment_api(called_method_name)
67
+ PricingPlans.deprecator.warn(
68
+ "`#{called_method_name}` is deprecated because assigning a plan creates a " \
69
+ "persistent pricing plan override. Use `override_pricing_plan!(plan_key, " \
70
+ "source: ...)` instead."
71
+ )
72
+ end
73
+
74
+ def default_plan_assignment_message(plan_key, called_method_name)
75
+ "`#{called_method_name}` was asked to assign the configured default plan " \
76
+ ":#{plan_key}. That creates a persistent pricing plan override; it does not " \
77
+ "select the normal default-plan resolution and it will take precedence over " \
78
+ "future subscriptions. Use `override_pricing_plan!(:#{plan_key}, source: ...)` " \
79
+ "when the override is intentional, or `clear_pricing_plan_override!` to use " \
80
+ "normal subscription/default resolution."
81
+ end
82
+ end
83
+ end
84
+ end
@@ -33,8 +33,21 @@ module PricingPlans
33
33
  PlanResolver.effective_plan_for(self)
34
34
  end
35
35
 
36
+ define_singleton_method :override_pricing_plan_for! do |plan_owner, plan_key, source:|
37
+ PlanResolver.override_pricing_plan_for!(plan_owner, plan_key, source: source)
38
+ end
39
+
40
+ define_singleton_method :clear_pricing_plan_override_for! do |plan_owner|
41
+ PlanResolver.clear_pricing_plan_override_for!(plan_owner)
42
+ end
43
+
36
44
  define_singleton_method :assign_pricing_plan! do |plan_owner, plan_key, source: "manual"|
37
- Assignment.assign_plan_to(plan_owner, plan_key, source: source)
45
+ LegacyPlanAssignmentApi.create_or_update_override!(
46
+ plan_owner,
47
+ plan_key,
48
+ source: source,
49
+ called_method_name: "#{name}.assign_pricing_plan!"
50
+ )
38
51
  end
39
52
  end
40
53
 
@@ -20,7 +20,7 @@ module PricingPlans
20
20
  Registry.plan(plan_key.to_sym)
21
21
  end
22
22
 
23
- def self.assign_plan_to(plan_owner, plan_key, source: "manual")
23
+ def self.create_or_update_pricing_plan_override_for!(plan_owner, plan_key, source:)
24
24
  assignment = find_or_initialize_by(
25
25
  plan_owner_type: plan_owner.class.name,
26
26
  plan_owner_id: plan_owner.id
@@ -35,13 +35,29 @@ module PricingPlans
35
35
  assignment
36
36
  end
37
37
 
38
- def self.remove_assignment_for(plan_owner)
38
+ def self.clear_pricing_plan_override_for!(plan_owner)
39
39
  where(
40
40
  plan_owner_type: plan_owner.class.name,
41
41
  plan_owner_id: plan_owner.id
42
42
  ).destroy_all
43
43
  end
44
44
 
45
+ def self.assign_plan_to(plan_owner, plan_key, source: "manual")
46
+ LegacyPlanAssignmentApi.create_or_update_override!(
47
+ plan_owner,
48
+ plan_key,
49
+ source: source,
50
+ called_method_name: "PricingPlans::Assignment.assign_plan_to"
51
+ )
52
+ end
53
+
54
+ def self.remove_assignment_for(plan_owner)
55
+ LegacyPlanAssignmentApi.clear_override!(
56
+ plan_owner,
57
+ called_method_name: "PricingPlans::Assignment.remove_assignment_for"
58
+ )
59
+ end
60
+
45
61
  private
46
62
 
47
63
  def plan_exists_in_registry
@@ -212,21 +212,49 @@ module PricingPlans
212
212
  end
213
213
 
214
214
  def has_plan_assignment?
215
+ pricing_plan_overridden?
216
+ end
217
+
218
+ def pricing_plan_overridden?
215
219
  return false unless respond_to?(:id) && id.present?
216
220
  Assignment.exists?(plan_owner_type: self.class.name, plan_owner_id: id)
217
221
  end
218
222
 
219
223
  def plan_assignment
224
+ pricing_plan_override
225
+ end
226
+
227
+ def pricing_plan_override
220
228
  return nil unless respond_to?(:id) && id.present?
221
229
  Assignment.find_by(plan_owner_type: self.class.name, plan_owner_id: id)
222
230
  end
223
231
 
232
+ def pricing_plan_override_source
233
+ pricing_plan_override&.source
234
+ end
235
+
236
+ def override_pricing_plan!(plan_key, source:)
237
+ PlanResolver.override_pricing_plan_for!(self, plan_key, source: source)
238
+ end
239
+
240
+ def clear_pricing_plan_override!
241
+ PlanResolver.clear_pricing_plan_override_for!(self)
242
+ end
243
+
224
244
  def assign_pricing_plan!(plan_key, source: "manual")
225
- Assignment.assign_plan_to(self, plan_key, source: source)
245
+ LegacyPlanAssignmentApi.create_or_update_override!(
246
+ self,
247
+ plan_key,
248
+ source: source,
249
+ called_method_name: "assign_pricing_plan!"
250
+ )
226
251
  end
227
252
 
228
253
  def remove_pricing_plan!
229
- Assignment.remove_assignment_for(self)
254
+ LegacyPlanAssignmentApi.clear_override!(
255
+ self,
256
+ called_method_name: "remove_pricing_plan!"
257
+ )
230
258
  end
231
259
 
232
260
  # Features
@@ -18,6 +18,10 @@ module PricingPlans
18
18
  source == :assignment
19
19
  end
20
20
 
21
+ def pricing_plan_overridden?
22
+ assignment?
23
+ end
24
+
21
25
  def subscription?
22
26
  source == :subscription
23
27
  end
@@ -34,12 +38,23 @@ module PricingPlans
34
38
  assignment&.source
35
39
  end
36
40
 
41
+ def pricing_plan_override
42
+ assignment
43
+ end
44
+
45
+ def pricing_plan_override_source
46
+ assignment_source
47
+ end
48
+
37
49
  # Extends Struct#to_h with derived fields.
38
50
  # Note: this preserves the raw plan / assignment / subscription objects.
39
51
  def to_h
40
52
  super.merge(
41
53
  plan_key: plan_key,
42
- assignment_source: assignment_source
54
+ assignment_source: assignment_source,
55
+ pricing_plan_overridden: pricing_plan_overridden?,
56
+ pricing_plan_override: pricing_plan_override,
57
+ pricing_plan_override_source: pricing_plan_override_source
43
58
  )
44
59
  end
45
60
  end
@@ -18,11 +18,11 @@ module PricingPlans
18
18
  def resolution_for(plan_owner)
19
19
  log_debug "[PricingPlans::PlanResolver] resolution_for called for #{plan_owner.class.name}##{plan_owner.respond_to?(:id) ? plan_owner.id : 'N/A'}"
20
20
 
21
- assignment = assignment_for(plan_owner)
21
+ assignment = pricing_plan_override_for(plan_owner)
22
22
  subscription = current_subscription_for(plan_owner)
23
23
 
24
24
  if assignment
25
- log_debug "[PricingPlans::PlanResolver] Returning assignment-backed resolution: #{assignment.plan_key}"
25
+ log_debug "[PricingPlans::PlanResolver] Returning explicit-override resolution: #{assignment.plan_key}"
26
26
  return PlanResolution.new(
27
27
  plan: Registry.plan(assignment.plan_key),
28
28
  source: :assignment,
@@ -56,12 +56,32 @@ module PricingPlans
56
56
  )
57
57
  end
58
58
 
59
+ def override_pricing_plan_for!(plan_owner, plan_key, source:)
60
+ Assignment.create_or_update_pricing_plan_override_for!(
61
+ plan_owner,
62
+ plan_key,
63
+ source: source
64
+ )
65
+ end
66
+
67
+ def clear_pricing_plan_override_for!(plan_owner)
68
+ Assignment.clear_pricing_plan_override_for!(plan_owner)
69
+ end
70
+
59
71
  def assign_plan_manually!(plan_owner, plan_key, source: "manual")
60
- Assignment.assign_plan_to(plan_owner, plan_key, source: source)
72
+ LegacyPlanAssignmentApi.create_or_update_override!(
73
+ plan_owner,
74
+ plan_key,
75
+ source: source,
76
+ called_method_name: "PricingPlans::PlanResolver.assign_plan_manually!"
77
+ )
61
78
  end
62
79
 
63
80
  def remove_manual_assignment!(plan_owner)
64
- Assignment.remove_assignment_for(plan_owner)
81
+ LegacyPlanAssignmentApi.clear_override!(
82
+ plan_owner,
83
+ called_method_name: "PricingPlans::PlanResolver.remove_manual_assignment!"
84
+ )
65
85
  end
66
86
 
67
87
  private
@@ -71,8 +91,8 @@ module PricingPlans
71
91
  PaySupport.pay_available?
72
92
  end
73
93
 
74
- def assignment_for(plan_owner)
75
- log_debug "[PricingPlans::PlanResolver] Checking for manual assignment..."
94
+ def pricing_plan_override_for(plan_owner)
95
+ log_debug "[PricingPlans::PlanResolver] Checking for an explicit pricing plan override..."
76
96
  return nil unless plan_owner.respond_to?(:id)
77
97
 
78
98
  assignment = Assignment.find_by(
@@ -81,9 +101,9 @@ module PricingPlans
81
101
  )
82
102
 
83
103
  if assignment
84
- log_debug "[PricingPlans::PlanResolver] Found manual assignment: #{assignment.plan_key}"
104
+ log_debug "[PricingPlans::PlanResolver] Found explicit pricing plan override: #{assignment.plan_key}"
85
105
  else
86
- log_debug "[PricingPlans::PlanResolver] No manual assignment found"
106
+ log_debug "[PricingPlans::PlanResolver] No explicit pricing plan override found"
87
107
  end
88
108
 
89
109
  assignment
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PricingPlans
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/pricing_plans.rb CHANGED
@@ -1,12 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "pricing_plans/version"
4
+ require "active_support/deprecation"
4
5
  require_relative "pricing_plans/engine" if defined?(Rails::Engine)
5
6
 
6
7
  module PricingPlans
7
8
  class Error < StandardError; end
8
9
  class ConfigurationError < Error; end
9
10
  class PlanNotFoundError < Error; end
11
+
12
+ class LegacyDefaultPlanAssignmentError < Error
13
+ attr_reader :configured_default_plan_key, :legacy_assignment_method_name
14
+
15
+ def initialize(message, configured_default_plan_key:, legacy_assignment_method_name:)
16
+ super(message)
17
+ @configured_default_plan_key = configured_default_plan_key.to_sym
18
+ @legacy_assignment_method_name = legacy_assignment_method_name
19
+ end
20
+ end
21
+
10
22
  class FeatureDenied < Error
11
23
  attr_reader :feature_key, :plan_owner
12
24
 
@@ -25,6 +37,7 @@ module PricingPlans
25
37
  autoload :DSL, "pricing_plans/dsl"
26
38
  autoload :IntegerRefinements, "pricing_plans/integer_refinements"
27
39
  autoload :PlanResolver, "pricing_plans/plan_resolver"
40
+ autoload :LegacyPlanAssignmentApi, "pricing_plans/legacy_plan_assignment_api"
28
41
  autoload :PaySupport, "pricing_plans/pay_support"
29
42
  autoload :LimitChecker, "pricing_plans/limit_checker"
30
43
  autoload :LimitableRegistry, "pricing_plans/limit_checker"
@@ -56,6 +69,13 @@ module PricingPlans
56
69
  @configuration ||= Configuration.new
57
70
  end
58
71
 
72
+ # A gem-specific deprecator lets host applications configure pricing_plans
73
+ # warnings independently and lets Rails include them in its deprecator
74
+ # collection.
75
+ def deprecator
76
+ @deprecator ||= ActiveSupport::Deprecation.new("0.6.0", "pricing_plans")
77
+ end
78
+
59
79
  def configure(&block)
60
80
  # Support both styles simultaneously inside the block:
61
81
  # - Bare DSL: plan :free { ... }
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pricing_plans
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-24 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -98,6 +98,7 @@ files:
98
98
  - lib/pricing_plans/grace_manager.rb
99
99
  - lib/pricing_plans/integer_refinements.rb
100
100
  - lib/pricing_plans/job_guards.rb
101
+ - lib/pricing_plans/legacy_plan_assignment_api.rb
101
102
  - lib/pricing_plans/limit_checker.rb
102
103
  - lib/pricing_plans/limitable.rb
103
104
  - lib/pricing_plans/models/assignment.rb
@@ -141,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
142
  - !ruby/object:Gem::Version
142
143
  version: '0'
143
144
  requirements: []
144
- rubygems_version: 3.6.2
145
+ rubygems_version: 3.6.9
145
146
  specification_version: 4
146
147
  summary: Define and enforce pricing plan limits (entitlements, quotas, feature gating)
147
148
  in your Rails SaaS