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 +4 -4
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +11 -0
- data/README.md +18 -5
- data/docs/01-define-pricing-plans.md +2 -2
- data/docs/03-model-helpers.md +63 -12
- data/docs/06-gem-compatibility.md +13 -1
- data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +1 -2
- data/lib/generators/pricing_plans/install/templates/initializer.rb +5 -0
- data/lib/pricing_plans/configuration.rb +21 -0
- data/lib/pricing_plans/engine.rb +4 -0
- data/lib/pricing_plans/legacy_plan_assignment_api.rb +84 -0
- data/lib/pricing_plans/limitable.rb +14 -1
- data/lib/pricing_plans/models/assignment.rb +18 -2
- data/lib/pricing_plans/plan_owner.rb +30 -2
- data/lib/pricing_plans/plan_resolution.rb +16 -1
- data/lib/pricing_plans/plan_resolver.rb +28 -8
- data/lib/pricing_plans/version.rb +1 -1
- data/lib/pricing_plans.rb +20 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8003fe05eaafc824f16d2c6dd72f067c04fd9417f066a1a9e9197f42d30518e0
|
|
4
|
+
data.tar.gz: 512a6cc888ce5261ecd37fb7b59e9bd5c16c3571d754154b0ee0e30c385e9c36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 617a80ea706ea417eb1f8ad8a8676c9690d852a66b454793ba47efe2bb4d112873b13f8a7e7fec7e6e9c9ed7833ae3d36f834be519f9b90bbc4ef18cac6de6d9
|
|
7
|
+
data.tar.gz: 6e86e014b7a3652a48725677819c324fedf23f924db740f30a2f2cf05a3f138673edf8b9d2d95a8c9fcd915e75438613b335fefad3867c1be0fb10389df71353
|
data/.rubocop.yml
CHANGED
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`
|
|
166
|
-
- What:
|
|
167
|
-
- How it's used: `PlanResolver` checks
|
|
168
|
-
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
data/docs/03-model-helpers.md
CHANGED
|
@@ -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
|
-
|
|
298
|
-
user.
|
|
299
|
-
user.
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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.
|
|
314
|
-
resolution.
|
|
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**
|
|
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,
|
|
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
|
|
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
|
data/lib/pricing_plans/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
log_debug "[PricingPlans::PlanResolver] Checking for
|
|
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
|
|
104
|
+
log_debug "[PricingPlans::PlanResolver] Found explicit pricing plan override: #{assignment.plan_key}"
|
|
85
105
|
else
|
|
86
|
-
log_debug "[PricingPlans::PlanResolver] No
|
|
106
|
+
log_debug "[PricingPlans::PlanResolver] No explicit pricing plan override found"
|
|
87
107
|
end
|
|
88
108
|
|
|
89
109
|
assignment
|
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
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
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.
|
|
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
|