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 +4 -4
- data/.rubocop.yml +5 -8
- data/.simplecov +6 -5
- data/Appraisals +5 -1
- data/CHANGELOG.md +30 -0
- data/README.md +66 -6
- data/docs/01-define-pricing-plans.md +8 -3
- data/docs/03-model-helpers.md +79 -12
- data/docs/06-gem-compatibility.md +48 -1
- data/docs/07-repricing.md +98 -0
- data/gemfiles/rails_7.1.gemfile +26 -0
- data/gemfiles/rails_7.2.gemfile +3 -2
- data/gemfiles/rails_8.1.gemfile +2 -1
- data/lib/generators/pricing_plans/grants/grants_generator.rb +43 -0
- data/lib/generators/pricing_plans/grants/templates/create_pricing_plans_feature_grants.rb.erb +32 -0
- data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +23 -10
- data/lib/generators/pricing_plans/install/templates/initializer.rb +11 -0
- data/lib/pricing_plans/configuration.rb +21 -0
- data/lib/pricing_plans/engine.rb +5 -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 +20 -6
- data/lib/pricing_plans/models/feature_grant.rb +139 -0
- data/lib/pricing_plans/overage_reporter.rb +7 -1
- data/lib/pricing_plans/pay_support.rb +92 -100
- data/lib/pricing_plans/plan.rb +108 -7
- data/lib/pricing_plans/plan_owner.rb +104 -10
- data/lib/pricing_plans/plan_owner_identity.rb +30 -0
- data/lib/pricing_plans/plan_resolution.rb +16 -1
- data/lib/pricing_plans/plan_resolver.rb +81 -45
- data/lib/pricing_plans/version.rb +1 -1
- data/lib/pricing_plans.rb +22 -0
- metadata +10 -3
data/gemfiles/rails_7.2.gemfile
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
source "https://rubygems.org"
|
|
4
4
|
|
|
5
5
|
gem "rake", "~> 13.0"
|
|
6
|
-
gem "rails", "~> 7.2.3"
|
|
6
|
+
gem "rails", "~> 7.2.3", ">= 7.2.3.2"
|
|
7
7
|
|
|
8
8
|
group :development do
|
|
9
9
|
gem "irb"
|
|
@@ -14,9 +14,10 @@ end
|
|
|
14
14
|
|
|
15
15
|
group :development, :test do
|
|
16
16
|
gem "appraisal"
|
|
17
|
-
gem "minitest", "
|
|
17
|
+
gem "minitest", ">= 5.16", "< 7"
|
|
18
18
|
gem "minitest-mock"
|
|
19
19
|
gem "rack-test"
|
|
20
|
+
gem "railties", ">= 7.1", "< 9"
|
|
20
21
|
gem "sqlite3", ">= 2.1"
|
|
21
22
|
gem "ostruct"
|
|
22
23
|
gem "simplecov", require: false
|
data/gemfiles/rails_8.1.gemfile
CHANGED
|
@@ -14,9 +14,10 @@ end
|
|
|
14
14
|
|
|
15
15
|
group :development, :test do
|
|
16
16
|
gem "appraisal"
|
|
17
|
-
gem "minitest", "
|
|
17
|
+
gem "minitest", ">= 5.16", "< 7"
|
|
18
18
|
gem "minitest-mock"
|
|
19
19
|
gem "rack-test"
|
|
20
|
+
gem "railties", ">= 7.1", "< 9"
|
|
20
21
|
gem "sqlite3", ">= 2.1"
|
|
21
22
|
gem "ostruct"
|
|
22
23
|
gem "simplecov", require: false
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module PricingPlans
|
|
7
|
+
module Generators
|
|
8
|
+
# Adds the pricing_plans_feature_grants table to apps that installed
|
|
9
|
+
# pricing_plans before per-owner feature grants existed (< 0.6.0).
|
|
10
|
+
# Fresh installs get the table from the install generator and never
|
|
11
|
+
# need this one.
|
|
12
|
+
class GrantsGenerator < Rails::Generators::Base
|
|
13
|
+
include ActiveRecord::Generators::Migration
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
desc "Add the pricing_plans_feature_grants table (upgrade from pricing_plans < 0.6.0)"
|
|
17
|
+
|
|
18
|
+
def self.next_migration_number(dir)
|
|
19
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_migration_file
|
|
23
|
+
migration_template "create_pricing_plans_feature_grants.rb.erb",
|
|
24
|
+
File.join(db_migrate_path, "create_pricing_plans_feature_grants.rb"),
|
|
25
|
+
migration_version: migration_version
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def display_post_install_message
|
|
29
|
+
say "\n✅ Feature grants migration created.", :green
|
|
30
|
+
say "Run 'rails db:migrate', then grant per-owner features with e.g. " \
|
|
31
|
+
"`owner.grant_feature!(:some_feature, note: \"comp\")`."
|
|
32
|
+
say "Note: declarative grandfathering " \
|
|
33
|
+
"(`grandfather :feature, subscribed_before: ...` in a plan) needs no table at all."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def migration_version
|
|
39
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreatePricingPlansFeatureGrants < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
|
6
|
+
|
|
7
|
+
create_table :pricing_plans_feature_grants, id: primary_key_type do |t|
|
|
8
|
+
t.references :plan_owner, polymorphic: true, null: false, type: foreign_key_type
|
|
9
|
+
t.string :feature_key, null: false
|
|
10
|
+
t.string :source, null: false
|
|
11
|
+
t.text :note
|
|
12
|
+
t.datetime :expires_at
|
|
13
|
+
t.datetime :revoked_at
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :pricing_plans_feature_grants,
|
|
19
|
+
[ :plan_owner_type, :plan_owner_id, :feature_key ],
|
|
20
|
+
name: "idx_pricing_plans_feature_grants_lookup"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def primary_and_foreign_key_types
|
|
26
|
+
config = Rails.configuration.generators
|
|
27
|
+
setting = config.options[config.orm][:primary_key_type]
|
|
28
|
+
primary_key_type = setting || :primary_key
|
|
29
|
+
foreign_key_type = setting || :bigint
|
|
30
|
+
[ primary_key_type, foreign_key_type ]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -17,12 +17,12 @@ class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
add_index :pricing_plans_enforcement_states,
|
|
20
|
-
[:plan_owner_type, :plan_owner_id, :limit_key],
|
|
20
|
+
[ :plan_owner_type, :plan_owner_id, :limit_key ],
|
|
21
21
|
unique: true,
|
|
22
22
|
name: "idx_pricing_plans_enforcement_unique"
|
|
23
23
|
|
|
24
24
|
add_index :pricing_plans_enforcement_states,
|
|
25
|
-
[:plan_owner_type, :plan_owner_id],
|
|
25
|
+
[ :plan_owner_type, :plan_owner_id ],
|
|
26
26
|
name: "idx_pricing_plans_enforcement_plan_owner"
|
|
27
27
|
|
|
28
28
|
add_index :pricing_plans_enforcement_states,
|
|
@@ -42,34 +42,49 @@ class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
add_index :pricing_plans_usages,
|
|
45
|
-
[:plan_owner_type, :plan_owner_id, :limit_key, :period_start],
|
|
45
|
+
[ :plan_owner_type, :plan_owner_id, :limit_key, :period_start ],
|
|
46
46
|
unique: true,
|
|
47
47
|
name: "idx_pricing_plans_usages_unique"
|
|
48
48
|
|
|
49
49
|
add_index :pricing_plans_usages,
|
|
50
|
-
[:plan_owner_type, :plan_owner_id],
|
|
50
|
+
[ :plan_owner_type, :plan_owner_id ],
|
|
51
51
|
name: "idx_pricing_plans_usages_plan_owner"
|
|
52
52
|
|
|
53
53
|
add_index :pricing_plans_usages,
|
|
54
|
-
[:period_start, :period_end],
|
|
54
|
+
[ :period_start, :period_end ],
|
|
55
55
|
name: "idx_pricing_plans_usages_period"
|
|
56
56
|
|
|
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
|
|
64
64
|
|
|
65
65
|
add_index :pricing_plans_assignments,
|
|
66
|
-
[:plan_owner_type, :plan_owner_id],
|
|
66
|
+
[ :plan_owner_type, :plan_owner_id ],
|
|
67
67
|
unique: true,
|
|
68
68
|
name: "idx_pricing_plans_assignments_unique"
|
|
69
69
|
|
|
70
70
|
add_index :pricing_plans_assignments,
|
|
71
71
|
:plan_key,
|
|
72
72
|
name: "idx_pricing_plans_assignments_plan"
|
|
73
|
+
|
|
74
|
+
create_table :pricing_plans_feature_grants, id: primary_key_type do |t|
|
|
75
|
+
t.references :plan_owner, polymorphic: true, null: false, type: foreign_key_type
|
|
76
|
+
t.string :feature_key, null: false
|
|
77
|
+
t.string :source, null: false
|
|
78
|
+
t.text :note
|
|
79
|
+
t.datetime :expires_at
|
|
80
|
+
t.datetime :revoked_at
|
|
81
|
+
|
|
82
|
+
t.timestamps
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
add_index :pricing_plans_feature_grants,
|
|
86
|
+
[ :plan_owner_type, :plan_owner_id, :feature_key ],
|
|
87
|
+
name: "idx_pricing_plans_feature_grants_lookup"
|
|
73
88
|
end
|
|
74
89
|
|
|
75
90
|
private
|
|
@@ -79,7 +94,7 @@ class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
79
94
|
setting = config.options[config.orm][:primary_key_type]
|
|
80
95
|
primary_key_type = setting || :primary_key
|
|
81
96
|
foreign_key_type = setting || :bigint
|
|
82
|
-
[primary_key_type, foreign_key_type]
|
|
97
|
+
[ primary_key_type, foreign_key_type ]
|
|
83
98
|
end
|
|
84
99
|
|
|
85
100
|
def json_column_type
|
|
@@ -87,5 +102,3 @@ class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
87
102
|
:json
|
|
88
103
|
end
|
|
89
104
|
end
|
|
90
|
-
|
|
91
|
-
|
|
@@ -21,6 +21,12 @@ PricingPlans.configure do |config|
|
|
|
21
21
|
allows :api_access, :premium_features
|
|
22
22
|
limits :projects, to: 25, after_limit: :grace_then_block, grace: 7.days
|
|
23
23
|
|
|
24
|
+
# Repricing later? When you move a feature to a higher plan, existing
|
|
25
|
+
# subscribers keep it with one declarative line (no columns, no backfill):
|
|
26
|
+
# grandfather :premium_features, subscribed_before: "2027-01-01"
|
|
27
|
+
# One-off exceptions (comps, betas) are per-owner grants instead:
|
|
28
|
+
# owner.grant_feature!(:premium_features, note: "comp")
|
|
29
|
+
|
|
24
30
|
highlighted!
|
|
25
31
|
end
|
|
26
32
|
|
|
@@ -36,6 +42,11 @@ PricingPlans.configure do |config|
|
|
|
36
42
|
allows :api_access, :premium_features
|
|
37
43
|
end
|
|
38
44
|
|
|
45
|
+
# Reject the deprecated, ambiguous assignment API if it is used to assign
|
|
46
|
+
# the configured default plan. Use `override_pricing_plan!(..., source: ...)`
|
|
47
|
+
# for deliberate exceptions and no assignment at all for ordinary defaults.
|
|
48
|
+
config.legacy_default_plan_assignment_behavior = :raise
|
|
49
|
+
|
|
39
50
|
|
|
40
51
|
# Optional settings
|
|
41
52
|
|
|
@@ -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,12 +4,17 @@ 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
|
|
10
14
|
require "pricing_plans/models/enforcement_state"
|
|
11
15
|
require "pricing_plans/models/usage"
|
|
12
16
|
require "pricing_plans/models/assignment"
|
|
17
|
+
require "pricing_plans/models/feature_grant"
|
|
13
18
|
end
|
|
14
19
|
end
|
|
15
20
|
|
|
@@ -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,10 +20,9 @@ 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
|
-
|
|
26
|
-
plan_owner_id: plan_owner.id
|
|
25
|
+
PlanOwnerIdentity.conditions_for(plan_owner)
|
|
27
26
|
)
|
|
28
27
|
|
|
29
28
|
assignment.assign_attributes(
|
|
@@ -35,13 +34,28 @@ module PricingPlans
|
|
|
35
34
|
assignment
|
|
36
35
|
end
|
|
37
36
|
|
|
38
|
-
def self.
|
|
37
|
+
def self.clear_pricing_plan_override_for!(plan_owner)
|
|
39
38
|
where(
|
|
40
|
-
|
|
41
|
-
plan_owner_id: plan_owner.id
|
|
39
|
+
PlanOwnerIdentity.conditions_for(plan_owner)
|
|
42
40
|
).destroy_all
|
|
43
41
|
end
|
|
44
42
|
|
|
43
|
+
def self.assign_plan_to(plan_owner, plan_key, source: "manual")
|
|
44
|
+
LegacyPlanAssignmentApi.create_or_update_override!(
|
|
45
|
+
plan_owner,
|
|
46
|
+
plan_key,
|
|
47
|
+
source: source,
|
|
48
|
+
called_method_name: "PricingPlans::Assignment.assign_plan_to"
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.remove_assignment_for(plan_owner)
|
|
53
|
+
LegacyPlanAssignmentApi.clear_override!(
|
|
54
|
+
plan_owner,
|
|
55
|
+
called_method_name: "PricingPlans::Assignment.remove_assignment_for"
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
45
59
|
private
|
|
46
60
|
|
|
47
61
|
def plan_exists_in_registry
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
# A per-owner feature entitlement that overrides plan resolution: comps,
|
|
5
|
+
# beta access, sales exceptions, support remediation, or a grandfather
|
|
6
|
+
# promise that must survive cancellation. Where the `grandfather` plan DSL
|
|
7
|
+
# is cohort-level policy (config, no state), a grant is an individual,
|
|
8
|
+
# auditable exception (a row, with a reason).
|
|
9
|
+
#
|
|
10
|
+
# Grants are never deleted by the API: revoking stamps `revoked_at` so the
|
|
11
|
+
# answer to "why does this customer have this?" survives the revocation.
|
|
12
|
+
class FeatureGrant < ActiveRecord::Base
|
|
13
|
+
self.table_name = "pricing_plans_feature_grants"
|
|
14
|
+
|
|
15
|
+
belongs_to :plan_owner, polymorphic: true
|
|
16
|
+
|
|
17
|
+
validates :plan_owner, presence: true
|
|
18
|
+
validates :feature_key, presence: true
|
|
19
|
+
validates :source, presence: true
|
|
20
|
+
validate :expires_at_must_be_parseable
|
|
21
|
+
|
|
22
|
+
scope :for_feature, ->(feature_key) { where(feature_key: feature_key.to_s) }
|
|
23
|
+
scope :active, lambda {
|
|
24
|
+
where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def active?
|
|
28
|
+
revoked_at.nil? && (expires_at.nil? || expires_at > Time.current)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def revoke!(note: nil)
|
|
32
|
+
return self if revoked_at.present?
|
|
33
|
+
|
|
34
|
+
update!(revoked_at: Time.current, note: [self.note, note].compact.presence&.join(" | "))
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Idempotent: updates the active grant for (owner, feature) if one
|
|
40
|
+
# exists, otherwise creates it. Revoked grants stay behind as history.
|
|
41
|
+
def grant_to!(plan_owner, feature_key, source: "manual", note: nil, expires_at: nil)
|
|
42
|
+
ensure_table!
|
|
43
|
+
ensure_persisted_owner!(plan_owner)
|
|
44
|
+
|
|
45
|
+
# Serialize grant writes through the owner row. A lookup followed by
|
|
46
|
+
# create is otherwise race-prone, and a portable partial unique index
|
|
47
|
+
# cannot express "unrevoked and unexpired" consistently on every
|
|
48
|
+
# supported database.
|
|
49
|
+
with_locked_owner(plan_owner) do
|
|
50
|
+
grant = active.find_by(owner_conditions(plan_owner).merge(feature_key: feature_key.to_s))
|
|
51
|
+
if grant
|
|
52
|
+
grant.update!(source: source.to_s, note: note, expires_at: expires_at)
|
|
53
|
+
grant
|
|
54
|
+
else
|
|
55
|
+
create!(
|
|
56
|
+
plan_owner: plan_owner,
|
|
57
|
+
feature_key: feature_key.to_s,
|
|
58
|
+
source: source.to_s,
|
|
59
|
+
note: note,
|
|
60
|
+
expires_at: expires_at
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def revoke_for!(plan_owner, feature_key, note: nil)
|
|
67
|
+
ensure_table!
|
|
68
|
+
ensure_persisted_owner!(plan_owner)
|
|
69
|
+
|
|
70
|
+
with_locked_owner(plan_owner) do
|
|
71
|
+
active
|
|
72
|
+
.where(owner_conditions(plan_owner).merge(feature_key: feature_key.to_s))
|
|
73
|
+
.map { |grant| grant.revoke!(note: note) }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def active_for?(plan_owner, feature_key)
|
|
78
|
+
return false unless table_ready?
|
|
79
|
+
|
|
80
|
+
active.where(owner_conditions(plan_owner).merge(feature_key: feature_key.to_s)).exists?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The grants table ships with fresh installs; apps that installed an
|
|
84
|
+
# earlier version add it with `rails generate pricing_plans:grants`.
|
|
85
|
+
# Plan-level checks (`allows` and `grandfather`) work without it, so
|
|
86
|
+
# its absence only disables per-owner grants — silently for reads,
|
|
87
|
+
# loudly for writes.
|
|
88
|
+
def table_ready?
|
|
89
|
+
table_exists?
|
|
90
|
+
rescue ActiveRecord::ActiveRecordError
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def ensure_table!
|
|
97
|
+
return if table_ready?
|
|
98
|
+
|
|
99
|
+
raise ConfigurationError,
|
|
100
|
+
"The #{table_name} table does not exist. Run " \
|
|
101
|
+
"`rails generate pricing_plans:grants && rails db:migrate` to add it."
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def owner_conditions(plan_owner)
|
|
105
|
+
PlanOwnerIdentity.conditions_for(plan_owner)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def ensure_persisted_owner!(plan_owner)
|
|
109
|
+
persisted_active_record = plan_owner.respond_to?(:persisted?) &&
|
|
110
|
+
plan_owner.persisted? &&
|
|
111
|
+
plan_owner.class.respond_to?(:base_class)
|
|
112
|
+
return if persisted_active_record
|
|
113
|
+
|
|
114
|
+
raise ArgumentError, "plan_owner must be a persisted Active Record"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def with_locked_owner(plan_owner)
|
|
118
|
+
owner_class = plan_owner.class.base_class
|
|
119
|
+
|
|
120
|
+
owner_class.transaction do
|
|
121
|
+
# Lock a fresh copy so granting a feature never reloads or rejects a
|
|
122
|
+
# caller that happens to have unrelated unsaved changes.
|
|
123
|
+
owner_class.unscoped.lock.find(plan_owner.id)
|
|
124
|
+
yield
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def expires_at_must_be_parseable
|
|
132
|
+
raw_value = expires_at_before_type_cast
|
|
133
|
+
return if raw_value.nil? || (raw_value.is_a?(String) && raw_value.blank?)
|
|
134
|
+
return if expires_at.respond_to?(:to_time)
|
|
135
|
+
|
|
136
|
+
errors.add(:expires_at, "must be a valid time")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -10,6 +10,7 @@ module PricingPlans
|
|
|
10
10
|
:overage,
|
|
11
11
|
:grace_active,
|
|
12
12
|
:grace_ends_at,
|
|
13
|
+
:grace_window,
|
|
13
14
|
keyword_init: true
|
|
14
15
|
)
|
|
15
16
|
|
|
@@ -37,7 +38,12 @@ module PricingPlans
|
|
|
37
38
|
allowed: allowed,
|
|
38
39
|
overage: over_by,
|
|
39
40
|
grace_active: GraceManager.grace_active?(plan_owner, limit_key),
|
|
40
|
-
grace_ends_at: GraceManager.grace_ends_at(plan_owner, limit_key)
|
|
41
|
+
grace_ends_at: GraceManager.grace_ends_at(plan_owner, limit_key),
|
|
42
|
+
# The CONFIGURED window on the target plan (nil unless the limit
|
|
43
|
+
# is :grace_then_block, where alone grace is honored) — so
|
|
44
|
+
# downgrade/dunning UX can say "after a N-day grace window"
|
|
45
|
+
# without re-deriving enforcement rules.
|
|
46
|
+
grace_window: limit_config[:grace]
|
|
41
47
|
)
|
|
42
48
|
end.compact
|
|
43
49
|
end
|