pricing_plans 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -7
- data/.simplecov +6 -5
- data/Appraisals +5 -1
- data/CHANGELOG.md +34 -0
- data/README.md +64 -1
- data/docs/01-define-pricing-plans.md +6 -1
- data/docs/03-model-helpers.md +16 -0
- data/docs/06-gem-compatibility.md +35 -0
- data/docs/07-repricing.md +103 -0
- data/docs/08-feature-passes.md +192 -0
- data/gemfiles/rails_7.1.gemfile +26 -0
- data/gemfiles/rails_7.2.gemfile +3 -2
- data/gemfiles/rails_8.1.gemfile +2 -1
- data/lib/generators/pricing_plans/grants/grants_generator.rb +43 -0
- data/lib/generators/pricing_plans/grants/templates/create_pricing_plans_feature_grants.rb.erb +44 -0
- data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +29 -8
- data/lib/generators/pricing_plans/install/templates/initializer.rb +11 -0
- data/lib/generators/pricing_plans/passes/passes_generator.rb +31 -0
- data/lib/generators/pricing_plans/passes/templates/add_feature_pass_limits.rb.erb +19 -0
- data/lib/pricing_plans/engine.rb +1 -0
- data/lib/pricing_plans/feature_access.rb +110 -0
- data/lib/pricing_plans/models/assignment.rb +2 -4
- data/lib/pricing_plans/models/feature_grant.rb +212 -0
- data/lib/pricing_plans/overage_reporter.rb +7 -1
- data/lib/pricing_plans/pay_support.rb +92 -100
- data/lib/pricing_plans/plan.rb +113 -7
- data/lib/pricing_plans/plan_owner.rb +97 -7
- data/lib/pricing_plans/plan_owner_identity.rb +30 -0
- data/lib/pricing_plans/plan_resolver.rb +53 -37
- data/lib/pricing_plans/version.rb +1 -1
- data/lib/pricing_plans.rb +6 -1
- metadata +14 -4
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 7.1.0"
|
|
7
|
+
|
|
8
|
+
group :development do
|
|
9
|
+
gem "irb"
|
|
10
|
+
gem "rubocop", "~> 1.0"
|
|
11
|
+
gem "rubocop-minitest", "~> 0.35"
|
|
12
|
+
gem "rubocop-performance", "~> 1.0"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
group :development, :test do
|
|
16
|
+
gem "appraisal"
|
|
17
|
+
gem "minitest", ">= 5.16", "< 7"
|
|
18
|
+
gem "minitest-mock"
|
|
19
|
+
gem "rack-test"
|
|
20
|
+
gem "railties", ">= 7.1", "< 9"
|
|
21
|
+
gem "sqlite3", ">= 2.1"
|
|
22
|
+
gem "ostruct"
|
|
23
|
+
gem "simplecov", require: false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
gemspec path: "../"
|
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,44 @@
|
|
|
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.send(json_column_type, :limits, default: {}, null: false)
|
|
14
|
+
t.bigint :usage_limit
|
|
15
|
+
t.bigint :usage_count, default: 0, null: false
|
|
16
|
+
t.datetime :revoked_at
|
|
17
|
+
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
add_check_constraint :pricing_plans_feature_grants,
|
|
22
|
+
"usage_count >= 0 AND (usage_limit IS NULL OR usage_limit >= 0)",
|
|
23
|
+
name: "pricing_plans_feature_pass_usage_nonnegative"
|
|
24
|
+
|
|
25
|
+
add_index :pricing_plans_feature_grants,
|
|
26
|
+
[ :plan_owner_type, :plan_owner_id, :feature_key ],
|
|
27
|
+
name: "idx_pricing_plans_feature_grants_lookup"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def primary_and_foreign_key_types
|
|
33
|
+
config = Rails.configuration.generators
|
|
34
|
+
setting = config.options[config.orm][:primary_key_type]
|
|
35
|
+
primary_key_type = setting || :primary_key
|
|
36
|
+
foreign_key_type = setting || :bigint
|
|
37
|
+
[ primary_key_type, foreign_key_type ]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def json_column_type
|
|
41
|
+
return :jsonb if connection.adapter_name.downcase.include?("postgresql")
|
|
42
|
+
:json
|
|
43
|
+
end
|
|
44
|
+
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,16 +42,16 @@ 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|
|
|
@@ -63,13 +63,35 @@ class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
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.send(json_column_type, :limits, default: {}, null: false)
|
|
81
|
+
t.bigint :usage_limit
|
|
82
|
+
t.bigint :usage_count, default: 0, null: false
|
|
83
|
+
t.datetime :revoked_at
|
|
84
|
+
|
|
85
|
+
t.timestamps
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
add_check_constraint :pricing_plans_feature_grants,
|
|
89
|
+
"usage_count >= 0 AND (usage_limit IS NULL OR usage_limit >= 0)",
|
|
90
|
+
name: "pricing_plans_feature_pass_usage_nonnegative"
|
|
91
|
+
|
|
92
|
+
add_index :pricing_plans_feature_grants,
|
|
93
|
+
[ :plan_owner_type, :plan_owner_id, :feature_key ],
|
|
94
|
+
name: "idx_pricing_plans_feature_grants_lookup"
|
|
73
95
|
end
|
|
74
96
|
|
|
75
97
|
private
|
|
@@ -79,7 +101,7 @@ class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
79
101
|
setting = config.options[config.orm][:primary_key_type]
|
|
80
102
|
primary_key_type = setting || :primary_key
|
|
81
103
|
foreign_key_type = setting || :bigint
|
|
82
|
-
[primary_key_type, foreign_key_type]
|
|
104
|
+
[ primary_key_type, foreign_key_type ]
|
|
83
105
|
end
|
|
84
106
|
|
|
85
107
|
def json_column_type
|
|
@@ -87,4 +109,3 @@ class CreatePricingPlansTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
87
109
|
:json
|
|
88
110
|
end
|
|
89
111
|
end
|
|
90
|
-
|
|
@@ -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
|
|
|
@@ -141,3 +147,8 @@ PricingPlans.configure do |config|
|
|
|
141
147
|
# When set to true, detailed debug output will be printed to stdout, which can be helpful for troubleshooting.
|
|
142
148
|
# config.debug = false
|
|
143
149
|
end
|
|
150
|
+
|
|
151
|
+
# Individual sales evaluations (after migration; run from your app/admin service):
|
|
152
|
+
# owner.issue_feature_pass!(:api_access, source: "sales", expires_at: 3.months.from_now)
|
|
153
|
+
# Capacity/consumption offers require with_feature_access! at the write boundary.
|
|
154
|
+
# Full guide: https://github.com/rameerez/pricing_plans/blob/main/docs/08-feature-passes.md
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
class PassesGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
desc "Add capacity limits and cumulative usage to existing feature grants"
|
|
13
|
+
|
|
14
|
+
def self.next_migration_number(dir)
|
|
15
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create_migration_file
|
|
19
|
+
migration_template "add_feature_pass_limits.rb.erb",
|
|
20
|
+
File.join(db_migrate_path, "add_feature_pass_limits.rb"),
|
|
21
|
+
migration_version: migration_version
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def migration_version
|
|
27
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddFeaturePassLimits < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
add_column :pricing_plans_feature_grants, :limits, json_column_type, default: {}, null: false
|
|
6
|
+
add_column :pricing_plans_feature_grants, :usage_limit, :bigint
|
|
7
|
+
add_column :pricing_plans_feature_grants, :usage_count, :bigint, default: 0, null: false
|
|
8
|
+
add_check_constraint :pricing_plans_feature_grants,
|
|
9
|
+
"usage_count >= 0 AND (usage_limit IS NULL OR usage_limit >= 0)",
|
|
10
|
+
name: "pricing_plans_feature_pass_usage_nonnegative"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def json_column_type
|
|
16
|
+
return :jsonb if connection.adapter_name.downcase.include?("postgresql")
|
|
17
|
+
:json
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/pricing_plans/engine.rb
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class FeatureGrantConflict < Error; end
|
|
5
|
+
|
|
6
|
+
class FeatureLimitExceeded < FeatureDenied
|
|
7
|
+
attr_reader :limit_key, :allowed, :requested
|
|
8
|
+
|
|
9
|
+
def initialize(feature_key:, plan_owner:, limit_key:, allowed:, requested:)
|
|
10
|
+
@limit_key = limit_key
|
|
11
|
+
@allowed = allowed
|
|
12
|
+
@requested = requested
|
|
13
|
+
super("#{feature_key.to_s.humanize}: #{limit_key.to_s.humanize.downcase} limit exceeded " \
|
|
14
|
+
"(#{requested} requested, #{allowed} allowed).", feature_key: feature_key, plan_owner: plan_owner)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# One snapshot for presentation and preflight checks. Writes must use
|
|
19
|
+
# PlanOwner#with_feature_access! to resolve again under the owner row lock.
|
|
20
|
+
class FeatureAccess
|
|
21
|
+
MAX_INTEGER = (2**63) - 1
|
|
22
|
+
attr_reader :owner, :feature_key, :source, :grant, :limits
|
|
23
|
+
|
|
24
|
+
def self.validate_amount!(amount)
|
|
25
|
+
return amount if amount.is_a?(Integer) && amount.between?(0, MAX_INTEGER)
|
|
26
|
+
|
|
27
|
+
raise ArgumentError, "usage must be a nonnegative integer no larger than #{MAX_INTEGER}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.normalize_limits(limits)
|
|
31
|
+
raise ArgumentError, "limits must be a Hash" unless limits.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
limits.each_with_object({}) do |(key, amount), result|
|
|
34
|
+
unless (key.is_a?(String) || key.is_a?(Symbol)) && key.to_s.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
35
|
+
raise ArgumentError, "limit keys must be lowercase names such as storage_bytes"
|
|
36
|
+
end
|
|
37
|
+
raise ArgumentError, "duplicate limit #{key}" if result.key?(key.to_s)
|
|
38
|
+
|
|
39
|
+
result[key.to_s] = if amount == :unlimited || amount == "unlimited"
|
|
40
|
+
"unlimited"
|
|
41
|
+
else
|
|
42
|
+
validate_amount!(amount)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Named limits belong to a pass. Plan and grandfather access carry none:
|
|
48
|
+
# the gem answers "is this owner entitled?", and the app owns its plan
|
|
49
|
+
# quotas the way it always has.
|
|
50
|
+
def initialize(owner, feature_key)
|
|
51
|
+
@owner = owner
|
|
52
|
+
@feature_key = feature_key.to_sym
|
|
53
|
+
@source = owner.feature_entitlement_source(@feature_key)
|
|
54
|
+
@grant = owner.feature_grants.active.for_feature(@feature_key).first if source == :grant
|
|
55
|
+
@source = nil if source == :grant && !grant
|
|
56
|
+
@limits = (grant ? grant.pass_limits : {}).freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def allowed? = !source.nil?
|
|
60
|
+
def expires_at = grant&.expires_at
|
|
61
|
+
def usage_limit = grant&.pass_usage_limit
|
|
62
|
+
def usage_count = grant ? grant.pass_usage_count : 0
|
|
63
|
+
|
|
64
|
+
def limit(key)
|
|
65
|
+
value = limits.fetch(key.to_s, "unlimited")
|
|
66
|
+
value == "unlimited" ? :unlimited : value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def remaining_allowance
|
|
70
|
+
usage_limit ? [usage_limit - usage_count, 0].max : :unlimited
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def available?(amount: 0, usage: {})
|
|
74
|
+
check!(amount: amount, usage: usage)
|
|
75
|
+
true
|
|
76
|
+
rescue FeatureDenied
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def check!(amount: 0, usage: {})
|
|
81
|
+
self.class.validate_amount!(amount)
|
|
82
|
+
raise ArgumentError, "usage must be a Hash" unless usage.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
unless allowed?
|
|
85
|
+
raise FeatureDenied.new("#{feature_key.to_s.humanize} is not available.",
|
|
86
|
+
feature_key: feature_key, plan_owner: owner)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
check_capacity!(usage)
|
|
90
|
+
check_limit!(:usage, usage_limit, usage_count + amount) if usage_limit && amount.positive?
|
|
91
|
+
self
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def check_capacity!(usage)
|
|
97
|
+
usage.each do |key, value|
|
|
98
|
+
self.class.validate_amount!(value)
|
|
99
|
+
check_limit!(key.to_sym, limit(key), value)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def check_limit!(key, allowed, requested)
|
|
104
|
+
return if allowed == :unlimited || requested <= allowed
|
|
105
|
+
|
|
106
|
+
raise FeatureLimitExceeded.new(feature_key: feature_key, plan_owner: owner,
|
|
107
|
+
limit_key: key, allowed: allowed, requested: requested)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -22,8 +22,7 @@ module PricingPlans
|
|
|
22
22
|
|
|
23
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(
|
|
@@ -37,8 +36,7 @@ module PricingPlans
|
|
|
37
36
|
|
|
38
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
|
|
|
@@ -0,0 +1,212 @@
|
|
|
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
|
+
validate :pass_options_must_be_valid
|
|
22
|
+
|
|
23
|
+
scope :for_feature, ->(feature_key) { where(feature_key: feature_key.to_s) }
|
|
24
|
+
scope :active, lambda {
|
|
25
|
+
where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def active?
|
|
29
|
+
revoked_at.nil? && (expires_at.nil? || expires_at > Time.current)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def revoke!(note: nil)
|
|
33
|
+
self.class.with_owner_lock(plan_owner) do
|
|
34
|
+
reload
|
|
35
|
+
update!(revoked_at: Time.current, note: [self.note, note].compact.presence&.join(" | ")) unless revoked_at.present?
|
|
36
|
+
end
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pass_limits
|
|
41
|
+
has_attribute?(:limits) ? FeatureAccess.normalize_limits(self[:limits] || {}) : {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pass_usage_limit = has_attribute?(:usage_limit) ? self[:usage_limit] : nil
|
|
45
|
+
def pass_usage_count = has_attribute?(:usage_count) ? self[:usage_count] : 0
|
|
46
|
+
|
|
47
|
+
# Internal to PlanOwner#with_feature_access!, which calls this on a row it
|
|
48
|
+
# loaded fresh under the owner lock after FeatureAccess#check! passed. The
|
|
49
|
+
# lock is what makes check-then-increment safe; this method neither
|
|
50
|
+
# re-acquires it nor reloads. Keep this private: the database constraint
|
|
51
|
+
# rejects negative counters, but cannot enforce the allowance or expiry.
|
|
52
|
+
def record_usage!(amount)
|
|
53
|
+
self.class.ensure_pass_columns!
|
|
54
|
+
FeatureAccess.validate_amount!(amount)
|
|
55
|
+
raise FeatureDenied, "This feature pass is no longer active." unless active?
|
|
56
|
+
|
|
57
|
+
total = pass_usage_count + amount
|
|
58
|
+
FeatureAccess.validate_amount!(total)
|
|
59
|
+
if pass_usage_limit && total > pass_usage_limit
|
|
60
|
+
raise FeatureLimitExceeded.new(feature_key: feature_key, plan_owner: plan_owner,
|
|
61
|
+
limit_key: :usage, allowed: pass_usage_limit, requested: total)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
increment!(:usage_count, amount, touch: true)
|
|
65
|
+
end
|
|
66
|
+
private :record_usage!
|
|
67
|
+
|
|
68
|
+
# Revise a specific lifecycle without resetting consumption or resurrecting it.
|
|
69
|
+
def revise!(**options)
|
|
70
|
+
unknown = options.keys - [:expires_at, :note, :limits, :usage_limit]
|
|
71
|
+
raise ArgumentError, "unknown revision options: #{unknown.join(', ')}" if unknown.any?
|
|
72
|
+
self.class.ensure_pass_columns! if (options.keys & [:limits, :usage_limit]).any?
|
|
73
|
+
self.class.with_owner_lock(plan_owner) do
|
|
74
|
+
reload
|
|
75
|
+
raise FeatureGrantConflict, "This pass is no longer active; issue a new pass." unless active?
|
|
76
|
+
options[:limits] = FeatureAccess.normalize_limits(options[:limits]) if options.key?(:limits)
|
|
77
|
+
FeatureAccess.validate_amount!(options[:usage_limit]) if options.key?(:usage_limit) && !options[:usage_limit].nil?
|
|
78
|
+
update!(**options)
|
|
79
|
+
end
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
# Idempotent: updates the active grant for (owner, feature) if one
|
|
85
|
+
# exists, otherwise creates it. Revoked grants stay behind as history.
|
|
86
|
+
def grant_to!(plan_owner, feature_key, source: "manual", note: nil, expires_at: nil, replace: true, **options)
|
|
87
|
+
ensure_table!
|
|
88
|
+
ensure_persisted_owner!(plan_owner)
|
|
89
|
+
unknown = options.keys - [:limits, :usage_limit]
|
|
90
|
+
raise ArgumentError, "unknown pass options: #{unknown.join(', ')}" if unknown.any?
|
|
91
|
+
ensure_pass_columns! if options.any?
|
|
92
|
+
options[:limits] = FeatureAccess.normalize_limits(options[:limits]) if options.key?(:limits)
|
|
93
|
+
FeatureAccess.validate_amount!(options[:usage_limit]) if options.key?(:usage_limit) && !options[:usage_limit].nil?
|
|
94
|
+
|
|
95
|
+
# Serialize grant writes through the owner row. A lookup followed by
|
|
96
|
+
# create is otherwise race-prone, and a portable partial unique index
|
|
97
|
+
# cannot express "unrevoked and unexpired" consistently on every
|
|
98
|
+
# supported database.
|
|
99
|
+
with_locked_owner(plan_owner) do
|
|
100
|
+
grant = active.find_by(owner_conditions(plan_owner).merge(feature_key: feature_key.to_s))
|
|
101
|
+
if grant
|
|
102
|
+
raise FeatureGrantConflict, "An active grant already exists for #{feature_key}; revise it explicitly." unless replace
|
|
103
|
+
grant.update!(source: source.to_s, note: note, expires_at: expires_at, **options)
|
|
104
|
+
grant
|
|
105
|
+
else
|
|
106
|
+
create!(
|
|
107
|
+
plan_owner: plan_owner,
|
|
108
|
+
feature_key: feature_key.to_s,
|
|
109
|
+
source: source.to_s,
|
|
110
|
+
note: note,
|
|
111
|
+
expires_at: expires_at,
|
|
112
|
+
**options
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def revoke_for!(plan_owner, feature_key, note: nil)
|
|
119
|
+
ensure_table!
|
|
120
|
+
ensure_persisted_owner!(plan_owner)
|
|
121
|
+
|
|
122
|
+
with_locked_owner(plan_owner) do
|
|
123
|
+
active
|
|
124
|
+
.where(owner_conditions(plan_owner).merge(feature_key: feature_key.to_s))
|
|
125
|
+
.map { |grant| grant.revoke!(note: note) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def active_for?(plan_owner, feature_key)
|
|
130
|
+
return false unless table_ready?
|
|
131
|
+
|
|
132
|
+
active.where(owner_conditions(plan_owner).merge(feature_key: feature_key.to_s)).exists?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The grants table ships with fresh installs; apps that installed an
|
|
136
|
+
# earlier version add it with `rails generate pricing_plans:grants`.
|
|
137
|
+
# Plan-level checks (`allows` and `grandfather`) work without it, so
|
|
138
|
+
# its absence only disables per-owner grants — silently for reads,
|
|
139
|
+
# loudly for writes.
|
|
140
|
+
def table_ready?
|
|
141
|
+
table_exists?
|
|
142
|
+
rescue ActiveRecord::ActiveRecordError
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def ensure_pass_columns!
|
|
147
|
+
return if %w[limits usage_limit usage_count].all? { |name| column_names.include?(name) }
|
|
148
|
+
|
|
149
|
+
raise ConfigurationError, "Run `rails generate pricing_plans:passes && rails db:migrate` to add feature pass limits."
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def with_owner_lock(plan_owner, &block)
|
|
153
|
+
ensure_persisted_owner!(plan_owner)
|
|
154
|
+
with_locked_owner(plan_owner, &block)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def ensure_table!
|
|
160
|
+
return if table_ready?
|
|
161
|
+
|
|
162
|
+
raise ConfigurationError,
|
|
163
|
+
"The #{table_name} table does not exist. Run " \
|
|
164
|
+
"`rails generate pricing_plans:grants && rails db:migrate` to add it."
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def owner_conditions(plan_owner)
|
|
168
|
+
PlanOwnerIdentity.conditions_for(plan_owner)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def ensure_persisted_owner!(plan_owner)
|
|
172
|
+
persisted_active_record = plan_owner.respond_to?(:persisted?) &&
|
|
173
|
+
plan_owner.persisted? &&
|
|
174
|
+
plan_owner.class.respond_to?(:base_class)
|
|
175
|
+
return if persisted_active_record
|
|
176
|
+
|
|
177
|
+
raise ArgumentError, "plan_owner must be a persisted Active Record"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def with_locked_owner(plan_owner)
|
|
181
|
+
owner_class = plan_owner.class.base_class
|
|
182
|
+
|
|
183
|
+
owner_class.uncached do
|
|
184
|
+
owner_class.transaction(requires_new: true) do
|
|
185
|
+
# Lock a fresh copy without changing the caller's unsaved attributes.
|
|
186
|
+
# Bypass preflight query caches after waiting for another writer.
|
|
187
|
+
owner_class.unscoped.lock.find(plan_owner.id)
|
|
188
|
+
yield
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def pass_options_must_be_valid
|
|
197
|
+
pass_limits
|
|
198
|
+
FeatureAccess.validate_amount!(pass_usage_count)
|
|
199
|
+
FeatureAccess.validate_amount!(pass_usage_limit) unless pass_usage_limit.nil?
|
|
200
|
+
rescue ArgumentError => error
|
|
201
|
+
errors.add(:base, error.message)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def expires_at_must_be_parseable
|
|
205
|
+
raw_value = expires_at_before_type_cast
|
|
206
|
+
return if raw_value.nil? || (raw_value.is_a?(String) && raw_value.blank?)
|
|
207
|
+
return if expires_at.respond_to?(:to_time)
|
|
208
|
+
|
|
209
|
+
errors.add(:expires_at, "must be a valid time")
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
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
|