pricing_plans 0.1.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 +7 -0
- data/.claude/settings.local.json +16 -0
- data/.rubocop.yml +137 -0
- data/CHANGELOG.md +83 -0
- data/LICENSE.txt +21 -0
- data/README.md +241 -0
- data/Rakefile +15 -0
- data/docs/01-define-pricing-plans.md +372 -0
- data/docs/02-controller-helpers.md +223 -0
- data/docs/03-model-helpers.md +318 -0
- data/docs/04-views.md +121 -0
- data/docs/05-semantic-pricing.md +159 -0
- data/docs/06-gem-compatibility.md +99 -0
- data/docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg +0 -0
- data/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg +0 -0
- data/docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg +0 -0
- data/docs/images/product_creation_blocked.jpg +0 -0
- data/lib/generators/pricing_plans/install/install_generator.rb +42 -0
- data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +91 -0
- data/lib/generators/pricing_plans/install/templates/initializer.rb +100 -0
- data/lib/pricing_plans/association_limit_registry.rb +45 -0
- data/lib/pricing_plans/configuration.rb +189 -0
- data/lib/pricing_plans/controller_guards.rb +574 -0
- data/lib/pricing_plans/controller_rescues.rb +115 -0
- data/lib/pricing_plans/dsl.rb +44 -0
- data/lib/pricing_plans/engine.rb +69 -0
- data/lib/pricing_plans/grace_manager.rb +227 -0
- data/lib/pricing_plans/integer_refinements.rb +48 -0
- data/lib/pricing_plans/job_guards.rb +24 -0
- data/lib/pricing_plans/limit_checker.rb +157 -0
- data/lib/pricing_plans/limitable.rb +286 -0
- data/lib/pricing_plans/models/assignment.rb +55 -0
- data/lib/pricing_plans/models/enforcement_state.rb +45 -0
- data/lib/pricing_plans/models/usage.rb +51 -0
- data/lib/pricing_plans/overage_reporter.rb +77 -0
- data/lib/pricing_plans/pay_support.rb +85 -0
- data/lib/pricing_plans/period_calculator.rb +183 -0
- data/lib/pricing_plans/plan.rb +653 -0
- data/lib/pricing_plans/plan_owner.rb +287 -0
- data/lib/pricing_plans/plan_resolver.rb +85 -0
- data/lib/pricing_plans/price_components.rb +16 -0
- data/lib/pricing_plans/registry.rb +182 -0
- data/lib/pricing_plans/result.rb +109 -0
- data/lib/pricing_plans/version.rb +5 -0
- data/lib/pricing_plans/view_helpers.rb +58 -0
- data/lib/pricing_plans.rb +645 -0
- data/sig/pricing_plans.rbs +4 -0
- metadata +236 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
module Limitable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
# Track all limited_by configurations for this model
|
|
9
|
+
class_attribute :pricing_plans_limits, default: {}
|
|
10
|
+
|
|
11
|
+
# Callbacks for automatic tracking
|
|
12
|
+
after_create :increment_per_period_counters
|
|
13
|
+
after_destroy :decrement_persistent_counters
|
|
14
|
+
# Add plan_owner-centric convenience methods to instances of the plan_owner class
|
|
15
|
+
# when possible. These are no-ops if the model isn't the plan_owner itself.
|
|
16
|
+
define_method :within_plan_limits? do |limit_key, by: 1|
|
|
17
|
+
plan_owner = self
|
|
18
|
+
LimitChecker.within_limit?(plan_owner, limit_key, by: by)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
define_method :plan_limit_remaining do |limit_key|
|
|
22
|
+
plan_owner = self
|
|
23
|
+
LimitChecker.plan_limit_remaining(plan_owner, limit_key)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
define_method :plan_limit_percent_used do |limit_key|
|
|
27
|
+
plan_owner = self
|
|
28
|
+
LimitChecker.plan_limit_percent_used(plan_owner, limit_key)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
define_method :current_pricing_plan do
|
|
32
|
+
PlanResolver.effective_plan_for(self)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
define_singleton_method :assign_pricing_plan! do |plan_owner, plan_key, source: "manual"|
|
|
36
|
+
Assignment.assign_plan_to(plan_owner, plan_key, source: source)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class_methods do
|
|
41
|
+
# New ergonomic macro: limited_by_pricing_plans
|
|
42
|
+
# - Auto-includes this concern if not already included
|
|
43
|
+
# - Infers limit_key from model's collection/table name when not provided
|
|
44
|
+
# - Infers plan owner association from configured plan_owner_class (or common conventions)
|
|
45
|
+
# - Accepts `per:` to declare per-period allowances
|
|
46
|
+
def limited_by_pricing_plans(limit_key = nil, plan_owner: nil, per: nil, on: nil, error_after_limit: nil, count_scope: nil)
|
|
47
|
+
include PricingPlans::Limitable unless ancestors.include?(PricingPlans::Limitable)
|
|
48
|
+
|
|
49
|
+
inferred_limit_key = (limit_key || inferred_limit_key_for_model).to_sym
|
|
50
|
+
effective_owner = plan_owner || on
|
|
51
|
+
inferred_owner = infer_plan_owner_association(effective_owner)
|
|
52
|
+
|
|
53
|
+
limited_by(inferred_limit_key, plan_owner: inferred_owner, per: per, error_after_limit: error_after_limit, count_scope: count_scope)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Backing implementation used by both the classic and new macro
|
|
57
|
+
def limited_by(limit_key, plan_owner:, per: nil, error_after_limit: nil, source: nil, count_scope: nil)
|
|
58
|
+
limit_key = limit_key.to_sym
|
|
59
|
+
plan_owner_method = plan_owner.to_sym
|
|
60
|
+
|
|
61
|
+
# Store the configuration
|
|
62
|
+
self.pricing_plans_limits = pricing_plans_limits.merge(
|
|
63
|
+
limit_key => {
|
|
64
|
+
plan_owner_method: plan_owner_method,
|
|
65
|
+
per: per,
|
|
66
|
+
error_after_limit: error_after_limit,
|
|
67
|
+
source: source,
|
|
68
|
+
count_scope: count_scope
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Register counter only for persistent caps
|
|
73
|
+
unless per
|
|
74
|
+
source_proc = count_scope || source
|
|
75
|
+
PricingPlans::LimitableRegistry.register_counter(limit_key) do |plan_owner_instance|
|
|
76
|
+
# Base relation for this limited model and plan_owner
|
|
77
|
+
base_relation = relation_for_plan_owner(plan_owner_instance, plan_owner_method)
|
|
78
|
+
|
|
79
|
+
# Prefer plan-level count_scope if present; fallback to model-provided one
|
|
80
|
+
scope_cfg = begin
|
|
81
|
+
plan = PlanResolver.effective_plan_for(plan_owner_instance)
|
|
82
|
+
cfg = plan&.limit_for(limit_key)
|
|
83
|
+
cfg && cfg[:count_scope]
|
|
84
|
+
end
|
|
85
|
+
scope_cfg ||= source_proc if source_proc
|
|
86
|
+
|
|
87
|
+
relation = apply_count_scope(base_relation, scope_cfg, plan_owner_instance)
|
|
88
|
+
relation.respond_to?(:count) ? relation.count : base_relation.count
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Add validation to prevent creation when over limit
|
|
93
|
+
validate_limit_on_create(limit_key, plan_owner_method, per, error_after_limit)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def count_for_plan_owner(plan_owner_instance, plan_owner_method)
|
|
97
|
+
relation_for_plan_owner(plan_owner_instance, plan_owner_method).count
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def relation_for_plan_owner(plan_owner_instance, plan_owner_method)
|
|
101
|
+
joins_condition = if plan_owner_method == :self
|
|
102
|
+
{ id: plan_owner_instance.id }
|
|
103
|
+
else
|
|
104
|
+
{ plan_owner_method => plan_owner_instance }
|
|
105
|
+
end
|
|
106
|
+
where(joins_condition)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Apply a flexible count_scope to an ActiveRecord::Relation.
|
|
110
|
+
# Accepts Proc/Lambda, Symbol (scope name), Hash (where), or Array of these.
|
|
111
|
+
def apply_count_scope(relation, scope_cfg, plan_owner_instance)
|
|
112
|
+
return relation unless scope_cfg
|
|
113
|
+
|
|
114
|
+
case scope_cfg
|
|
115
|
+
when Array
|
|
116
|
+
scope_cfg.reduce(relation) { |rel, cfg| apply_count_scope(rel, cfg, plan_owner_instance) }
|
|
117
|
+
when Proc
|
|
118
|
+
# Support arity variants: (rel) or (rel, plan_owner)
|
|
119
|
+
case scope_cfg.arity
|
|
120
|
+
when 1 then scope_cfg.call(relation)
|
|
121
|
+
when 2 then scope_cfg.call(relation, plan_owner_instance)
|
|
122
|
+
else
|
|
123
|
+
relation.instance_exec(&scope_cfg)
|
|
124
|
+
end
|
|
125
|
+
when Symbol
|
|
126
|
+
if relation.respond_to?(scope_cfg)
|
|
127
|
+
relation.public_send(scope_cfg)
|
|
128
|
+
else
|
|
129
|
+
relation
|
|
130
|
+
end
|
|
131
|
+
when Hash
|
|
132
|
+
relation.where(scope_cfg)
|
|
133
|
+
else
|
|
134
|
+
relation
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def inferred_limit_key_for_model
|
|
141
|
+
# Prefer table_name (works for anonymous AR classes with explicit table_name)
|
|
142
|
+
return table_name if respond_to?(:table_name) && table_name
|
|
143
|
+
|
|
144
|
+
# Fallback to model_name.collection only if the class has a real name
|
|
145
|
+
if respond_to?(:name) && name && respond_to?(:model_name) && model_name.respond_to?(:collection)
|
|
146
|
+
return model_name.collection
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
raise PricingPlans::ConfigurationError, "Cannot infer limit key: provide one explicitly"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def infer_plan_owner_association(explicit)
|
|
153
|
+
return explicit.to_sym if explicit
|
|
154
|
+
|
|
155
|
+
# Prefer configured plan_owner_class association name if present
|
|
156
|
+
begin
|
|
157
|
+
plan_owner_klass = PricingPlans::Registry.plan_owner_class
|
|
158
|
+
rescue StandardError
|
|
159
|
+
plan_owner_klass = nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if plan_owner_klass
|
|
163
|
+
association_name = plan_owner_klass.name.underscore.to_sym
|
|
164
|
+
return association_name if reflect_on_association(association_name)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Common conventions fallback
|
|
168
|
+
%i[organization account user team company workspace tenant].each do |candidate|
|
|
169
|
+
return candidate if reflect_on_association(candidate)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# If nothing found, assume the record limits itself
|
|
173
|
+
:self
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_limit_on_create(limit_key, plan_owner_method, per, error_after_limit)
|
|
177
|
+
method_name = :"check_limit_on_create_#{limit_key}"
|
|
178
|
+
|
|
179
|
+
# Only define the method if it doesn't already exist
|
|
180
|
+
unless method_defined?(method_name)
|
|
181
|
+
validate method_name, on: :create
|
|
182
|
+
|
|
183
|
+
define_method method_name do
|
|
184
|
+
plan_owner_instance = if plan_owner_method == :self
|
|
185
|
+
self
|
|
186
|
+
else
|
|
187
|
+
send(plan_owner_method)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
return unless plan_owner_instance
|
|
191
|
+
|
|
192
|
+
# Skip validation if the plan_owner doesn't have limits configured
|
|
193
|
+
plan = PlanResolver.effective_plan_for(plan_owner_instance)
|
|
194
|
+
limit_config = plan&.limit_for(limit_key)
|
|
195
|
+
return unless limit_config
|
|
196
|
+
return if limit_config[:to] == :unlimited
|
|
197
|
+
|
|
198
|
+
# For persistent caps, check if we'd exceed the limit
|
|
199
|
+
if per.nil?
|
|
200
|
+
current_count = self.class.count_for_plan_owner(plan_owner_instance, plan_owner_method)
|
|
201
|
+
if current_count >= limit_config[:to]
|
|
202
|
+
# Check grace/block policy
|
|
203
|
+
case limit_config[:after_limit]
|
|
204
|
+
when :just_warn
|
|
205
|
+
# Allow creation with warning
|
|
206
|
+
return
|
|
207
|
+
when :block_usage, :grace_then_block
|
|
208
|
+
if limit_config[:after_limit] == :block_usage || GraceManager.should_block?(plan_owner_instance, limit_key)
|
|
209
|
+
message = error_after_limit || "Cannot create #{self.class.name.downcase}: #{limit_key} limit exceeded"
|
|
210
|
+
errors.add(:base, message)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
else
|
|
215
|
+
# For per-period limits, check usage in current period
|
|
216
|
+
current_usage = LimitChecker.current_usage_for(plan_owner_instance, limit_key, limit_config)
|
|
217
|
+
if current_usage >= limit_config[:to]
|
|
218
|
+
case limit_config[:after_limit]
|
|
219
|
+
when :just_warn
|
|
220
|
+
return
|
|
221
|
+
when :block_usage, :grace_then_block
|
|
222
|
+
if limit_config[:after_limit] == :block_usage || GraceManager.should_block?(plan_owner_instance, limit_key)
|
|
223
|
+
message = error_after_limit || "Cannot create #{self.class.name.downcase}: #{limit_key} limit exceeded for this period"
|
|
224
|
+
errors.add(:base, message)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
def increment_per_period_counters
|
|
237
|
+
self.class.pricing_plans_limits.each do |limit_key, config|
|
|
238
|
+
next unless config[:per] # Only per-period limits
|
|
239
|
+
|
|
240
|
+
plan_owner_instance = if config[:plan_owner_method] == :self
|
|
241
|
+
self
|
|
242
|
+
else
|
|
243
|
+
send(config[:plan_owner_method])
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
next unless plan_owner_instance
|
|
247
|
+
|
|
248
|
+
period_start, period_end = PeriodCalculator.window_for(plan_owner_instance, limit_key)
|
|
249
|
+
|
|
250
|
+
# Use upsert for better performance and concurrency handling
|
|
251
|
+
usage = Usage.find_or_initialize_by(
|
|
252
|
+
plan_owner: plan_owner_instance,
|
|
253
|
+
limit_key: limit_key.to_s,
|
|
254
|
+
period_start: period_start,
|
|
255
|
+
period_end: period_end
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if usage.new_record?
|
|
259
|
+
usage.used = 1
|
|
260
|
+
usage.last_used_at = Time.current
|
|
261
|
+
|
|
262
|
+
begin
|
|
263
|
+
usage.save!
|
|
264
|
+
rescue ActiveRecord::RecordNotUnique
|
|
265
|
+
# Handle race condition - record was created by another process
|
|
266
|
+
usage = Usage.find_by(
|
|
267
|
+
plan_owner: plan_owner_instance,
|
|
268
|
+
limit_key: limit_key.to_s,
|
|
269
|
+
period_start: period_start,
|
|
270
|
+
period_end: period_end
|
|
271
|
+
)
|
|
272
|
+
usage&.increment!
|
|
273
|
+
end
|
|
274
|
+
else
|
|
275
|
+
usage.increment!
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def decrement_persistent_counters
|
|
281
|
+
# For persistent caps, we don't need to do anything on destroy
|
|
282
|
+
# since the counter is computed live from the database
|
|
283
|
+
# The record being destroyed will automatically reduce the count
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class Assignment < ActiveRecord::Base
|
|
5
|
+
self.table_name = "pricing_plans_assignments"
|
|
6
|
+
|
|
7
|
+
belongs_to :plan_owner, polymorphic: true
|
|
8
|
+
|
|
9
|
+
validates :plan_owner, presence: true
|
|
10
|
+
validates :plan_key, presence: true
|
|
11
|
+
validates :source, presence: true
|
|
12
|
+
validates :plan_owner_type, uniqueness: { scope: :plan_owner_id }
|
|
13
|
+
|
|
14
|
+
validate :plan_exists_in_registry
|
|
15
|
+
|
|
16
|
+
scope :manual, -> { where(source: "manual") }
|
|
17
|
+
scope :for_plan, ->(plan_key) { where(plan_key: plan_key.to_s) }
|
|
18
|
+
|
|
19
|
+
def plan
|
|
20
|
+
Registry.plan(plan_key.to_sym)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.assign_plan_to(plan_owner, plan_key, source: "manual")
|
|
24
|
+
assignment = find_or_initialize_by(
|
|
25
|
+
plan_owner_type: plan_owner.class.name,
|
|
26
|
+
plan_owner_id: plan_owner.id
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
assignment.assign_attributes(
|
|
30
|
+
plan_key: plan_key.to_s,
|
|
31
|
+
source: source.to_s
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
assignment.save!
|
|
35
|
+
assignment
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.remove_assignment_for(plan_owner)
|
|
39
|
+
where(
|
|
40
|
+
plan_owner_type: plan_owner.class.name,
|
|
41
|
+
plan_owner_id: plan_owner.id
|
|
42
|
+
).destroy_all
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def plan_exists_in_registry
|
|
48
|
+
return unless plan_key.present?
|
|
49
|
+
|
|
50
|
+
unless Registry.plan_exists?(plan_key)
|
|
51
|
+
errors.add(:plan_key, "#{plan_key} is not a defined plan")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class EnforcementState < ActiveRecord::Base
|
|
5
|
+
self.table_name = "pricing_plans_enforcement_states"
|
|
6
|
+
|
|
7
|
+
belongs_to :plan_owner, polymorphic: true
|
|
8
|
+
|
|
9
|
+
validates :limit_key, presence: true
|
|
10
|
+
validates :plan_owner_type, :plan_owner_id, :limit_key, uniqueness: { scope: [:plan_owner_type, :plan_owner_id] }
|
|
11
|
+
|
|
12
|
+
scope :exceeded, -> { where.not(exceeded_at: nil) }
|
|
13
|
+
scope :blocked, -> { where.not(blocked_at: nil) }
|
|
14
|
+
scope :in_grace, -> { exceeded.where(blocked_at: nil) }
|
|
15
|
+
|
|
16
|
+
def exceeded?
|
|
17
|
+
exceeded_at.present?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def blocked?
|
|
21
|
+
blocked_at.present?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def in_grace?
|
|
25
|
+
exceeded? && !blocked?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def grace_ends_at
|
|
29
|
+
return nil unless exceeded_at && grace_period
|
|
30
|
+
exceeded_at + grace_period
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def grace_expired?
|
|
34
|
+
return false unless grace_ends_at
|
|
35
|
+
Time.current >= grace_ends_at
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def grace_period
|
|
41
|
+
# This will be set by the GraceManager based on the plan configuration
|
|
42
|
+
data&.dig("grace_period")&.seconds
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class Usage < ActiveRecord::Base
|
|
5
|
+
self.table_name = "pricing_plans_usages"
|
|
6
|
+
|
|
7
|
+
belongs_to :plan_owner, polymorphic: true
|
|
8
|
+
|
|
9
|
+
validates :limit_key, presence: true
|
|
10
|
+
validates :period_start, :period_end, presence: true
|
|
11
|
+
validates :used, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
12
|
+
validates :period_start, uniqueness: { scope: [:plan_owner_type, :plan_owner_id, :limit_key] }
|
|
13
|
+
|
|
14
|
+
validate :period_end_after_start
|
|
15
|
+
|
|
16
|
+
scope :current_period, ->(period_start, period_end) {
|
|
17
|
+
where(period_start: period_start, period_end: period_end)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
scope :for_limit, ->(limit_key) { where(limit_key: limit_key.to_s) }
|
|
21
|
+
|
|
22
|
+
def increment!(amount = 1)
|
|
23
|
+
increment(:used, amount)
|
|
24
|
+
update!(last_used_at: Time.current)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def within_period?(timestamp = Time.current)
|
|
28
|
+
timestamp >= period_start && timestamp < period_end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def remaining(limit_amount)
|
|
32
|
+
return Float::INFINITY if limit_amount == :unlimited
|
|
33
|
+
[0, limit_amount - used].max
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def percent_used(limit_amount)
|
|
37
|
+
return 0.0 if limit_amount == :unlimited || limit_amount.zero?
|
|
38
|
+
[(used.to_f / limit_amount) * 100, 100.0].min
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def period_end_after_start
|
|
44
|
+
return unless period_start && period_end
|
|
45
|
+
|
|
46
|
+
if period_end <= period_start
|
|
47
|
+
errors.add(:period_end, "must be after period_start")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class OverageReporter
|
|
5
|
+
OverageItem = Struct.new(
|
|
6
|
+
:limit_key,
|
|
7
|
+
:kind,
|
|
8
|
+
:current_usage,
|
|
9
|
+
:allowed,
|
|
10
|
+
:overage,
|
|
11
|
+
:grace_active,
|
|
12
|
+
:grace_ends_at,
|
|
13
|
+
keyword_init: true
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
Report = Struct.new(:items, :message, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Compute overage against a target plan for the given plan_owner.
|
|
20
|
+
# Returns an array of OverageItem for limits that are over the target.
|
|
21
|
+
# kind: :persistent or :per_period
|
|
22
|
+
def report(plan_owner, target_plan)
|
|
23
|
+
plan = target_plan.is_a?(PricingPlans::Plan) ? target_plan : Registry.plan(target_plan.to_sym)
|
|
24
|
+
|
|
25
|
+
plan.limits.map do |limit_key, limit_config|
|
|
26
|
+
next if limit_config[:to] == :unlimited
|
|
27
|
+
|
|
28
|
+
usage = LimitChecker.current_usage_for(plan_owner, limit_key, limit_config)
|
|
29
|
+
allowed = limit_config[:to]
|
|
30
|
+
over_by = [usage - allowed.to_i, 0].max
|
|
31
|
+
next if over_by <= 0
|
|
32
|
+
|
|
33
|
+
OverageItem.new(
|
|
34
|
+
limit_key: limit_key,
|
|
35
|
+
kind: (limit_config[:per] ? :per_period : :persistent),
|
|
36
|
+
current_usage: usage,
|
|
37
|
+
allowed: allowed,
|
|
38
|
+
overage: over_by,
|
|
39
|
+
grace_active: GraceManager.grace_active?(plan_owner, limit_key),
|
|
40
|
+
grace_ends_at: GraceManager.grace_ends_at(plan_owner, limit_key)
|
|
41
|
+
)
|
|
42
|
+
end.compact
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns a Report with items and a human message suitable for downgrade UX.
|
|
46
|
+
def report_with_message(plan_owner, target_plan)
|
|
47
|
+
items = report(plan_owner, target_plan)
|
|
48
|
+
return Report.new(items: [], message: "No overages on target plan") if items.empty?
|
|
49
|
+
|
|
50
|
+
parts = items.map do |i|
|
|
51
|
+
"#{i.limit_key}: #{i.current_usage} > #{i.allowed} (reduce by #{i.overage})"
|
|
52
|
+
end
|
|
53
|
+
grace_info = items.select(&:grace_active).map do |i|
|
|
54
|
+
ends = i.grace_ends_at&.utc&.iso8601
|
|
55
|
+
"#{i.limit_key} grace ends at #{ends}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
msg = if PricingPlans.configuration&.message_builder
|
|
59
|
+
begin
|
|
60
|
+
built = PricingPlans.configuration.message_builder.call(
|
|
61
|
+
context: :overage_report,
|
|
62
|
+
items: items
|
|
63
|
+
)
|
|
64
|
+
built if built
|
|
65
|
+
rescue StandardError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
msg ||= "Over target plan on: #{parts.join(', ')}. "
|
|
70
|
+
msg += "Grace active — #{grace_info.join(', ')}." unless grace_info.empty?
|
|
71
|
+
|
|
72
|
+
Report.new(items: items, message: msg)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
module PaySupport
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def pay_available?
|
|
8
|
+
defined?(Pay)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def subscription_active_for?(plan_owner)
|
|
12
|
+
return false unless plan_owner
|
|
13
|
+
|
|
14
|
+
# Prefer Pay's official API on the payment_processor
|
|
15
|
+
if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor)
|
|
16
|
+
return true if (pp.respond_to?(:subscribed?) && pp.subscribed?) ||
|
|
17
|
+
(pp.respond_to?(:on_trial?) && pp.on_trial?) ||
|
|
18
|
+
(pp.respond_to?(:on_grace_period?) && pp.on_grace_period?)
|
|
19
|
+
|
|
20
|
+
if pp.respond_to?(:subscriptions) && (subs = pp.subscriptions)
|
|
21
|
+
return subs.any? { |sub| (sub.respond_to?(:active?) && sub.active?) || (sub.respond_to?(:on_trial?) && sub.on_trial?) || (sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Fallbacks for apps that surface Pay state on the owner
|
|
26
|
+
individual_active = (plan_owner.respond_to?(:subscribed?) && plan_owner.subscribed?) ||
|
|
27
|
+
(plan_owner.respond_to?(:on_trial?) && plan_owner.on_trial?) ||
|
|
28
|
+
(plan_owner.respond_to?(:on_grace_period?) && plan_owner.on_grace_period?)
|
|
29
|
+
return true if individual_active
|
|
30
|
+
|
|
31
|
+
if plan_owner.respond_to?(:subscriptions) && (subs = plan_owner.subscriptions)
|
|
32
|
+
return subs.any? { |sub| (sub.respond_to?(:active?) && sub.active?) || (sub.respond_to?(:on_trial?) && sub.on_trial?) || (sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def current_subscription_for(plan_owner)
|
|
39
|
+
return nil unless pay_available?
|
|
40
|
+
|
|
41
|
+
# Prefer Pay's payment_processor API
|
|
42
|
+
if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor)
|
|
43
|
+
if pp.respond_to?(:subscription)
|
|
44
|
+
subscription = pp.subscription
|
|
45
|
+
if subscription && (
|
|
46
|
+
(subscription.respond_to?(:active?) && subscription.active?) ||
|
|
47
|
+
(subscription.respond_to?(:on_trial?) && subscription.on_trial?) ||
|
|
48
|
+
(subscription.respond_to?(:on_grace_period?) && subscription.on_grace_period?)
|
|
49
|
+
)
|
|
50
|
+
return subscription
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if pp.respond_to?(:subscriptions) && (subs = pp.subscriptions)
|
|
55
|
+
found = subs.find do |sub|
|
|
56
|
+
(sub.respond_to?(:on_trial?) && sub.on_trial?) ||
|
|
57
|
+
(sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) ||
|
|
58
|
+
(sub.respond_to?(:active?) && sub.active?)
|
|
59
|
+
end
|
|
60
|
+
return found if found
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Fallbacks for apps that surface subscriptions on the owner
|
|
65
|
+
if plan_owner.respond_to?(:subscription)
|
|
66
|
+
subscription = plan_owner.subscription
|
|
67
|
+
if subscription && (
|
|
68
|
+
(subscription.respond_to?(:active?) && subscription.active?) ||
|
|
69
|
+
(subscription.respond_to?(:on_trial?) && subscription.on_trial?) ||
|
|
70
|
+
(subscription.respond_to?(:on_grace_period?) && subscription.on_grace_period?)
|
|
71
|
+
)
|
|
72
|
+
return subscription
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if plan_owner.respond_to?(:subscriptions) && (subs = plan_owner.subscriptions)
|
|
77
|
+
subs.find do |sub|
|
|
78
|
+
(sub.respond_to?(:on_trial?) && sub.on_trial?) ||
|
|
79
|
+
(sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) ||
|
|
80
|
+
(sub.respond_to?(:active?) && sub.active?)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|