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.
@@ -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,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, default: "manual"
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
@@ -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
- Assignment.assign_plan_to(plan_owner, plan_key, source: source)
45
+ LegacyPlanAssignmentApi.create_or_update_override!(
46
+ plan_owner,
47
+ plan_key,
48
+ source: source,
49
+ called_method_name: "#{name}.assign_pricing_plan!"
50
+ )
38
51
  end
39
52
  end
40
53
 
@@ -20,10 +20,9 @@ module PricingPlans
20
20
  Registry.plan(plan_key.to_sym)
21
21
  end
22
22
 
23
- def self.assign_plan_to(plan_owner, plan_key, source: "manual")
23
+ def self.create_or_update_pricing_plan_override_for!(plan_owner, plan_key, source:)
24
24
  assignment = find_or_initialize_by(
25
- 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(
@@ -35,13 +34,28 @@ module PricingPlans
35
34
  assignment
36
35
  end
37
36
 
38
- def self.remove_assignment_for(plan_owner)
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
 
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