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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -7
  3. data/.simplecov +6 -5
  4. data/Appraisals +5 -1
  5. data/CHANGELOG.md +34 -0
  6. data/README.md +64 -1
  7. data/docs/01-define-pricing-plans.md +6 -1
  8. data/docs/03-model-helpers.md +16 -0
  9. data/docs/06-gem-compatibility.md +35 -0
  10. data/docs/07-repricing.md +103 -0
  11. data/docs/08-feature-passes.md +192 -0
  12. data/gemfiles/rails_7.1.gemfile +26 -0
  13. data/gemfiles/rails_7.2.gemfile +3 -2
  14. data/gemfiles/rails_8.1.gemfile +2 -1
  15. data/lib/generators/pricing_plans/grants/grants_generator.rb +43 -0
  16. data/lib/generators/pricing_plans/grants/templates/create_pricing_plans_feature_grants.rb.erb +44 -0
  17. data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +29 -8
  18. data/lib/generators/pricing_plans/install/templates/initializer.rb +11 -0
  19. data/lib/generators/pricing_plans/passes/passes_generator.rb +31 -0
  20. data/lib/generators/pricing_plans/passes/templates/add_feature_pass_limits.rb.erb +19 -0
  21. data/lib/pricing_plans/engine.rb +1 -0
  22. data/lib/pricing_plans/feature_access.rb +110 -0
  23. data/lib/pricing_plans/models/assignment.rb +2 -4
  24. data/lib/pricing_plans/models/feature_grant.rb +212 -0
  25. data/lib/pricing_plans/overage_reporter.rb +7 -1
  26. data/lib/pricing_plans/pay_support.rb +92 -100
  27. data/lib/pricing_plans/plan.rb +113 -7
  28. data/lib/pricing_plans/plan_owner.rb +97 -7
  29. data/lib/pricing_plans/plan_owner_identity.rb +30 -0
  30. data/lib/pricing_plans/plan_resolver.rb +53 -37
  31. data/lib/pricing_plans/version.rb +1 -1
  32. data/lib/pricing_plans.rb +6 -1
  33. 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: "../"
@@ -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", "~> 6.0"
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
@@ -14,9 +14,10 @@ end
14
14
 
15
15
  group :development, :test do
16
16
  gem "appraisal"
17
- gem "minitest", "~> 6.0"
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
@@ -14,6 +14,7 @@ module PricingPlans
14
14
  require "pricing_plans/models/enforcement_state"
15
15
  require "pricing_plans/models/usage"
16
16
  require "pricing_plans/models/assignment"
17
+ require "pricing_plans/models/feature_grant"
17
18
  end
18
19
  end
19
20
 
@@ -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
- plan_owner_type: plan_owner.class.name,
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
- plan_owner_type: plan_owner.class.name,
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