superfeature 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3b624d4e0260cabbdebb2675fc22a8236889d92197859336a19700528e23b07
4
- data.tar.gz: '09256cd31eea44abffe554ad183fb98ce061dcc416259d5b47b55651019edac2'
3
+ metadata.gz: ea2cc79603e2a605076a000f1c99224a460e812ca128b1685c9b2ccf84189de9
4
+ data.tar.gz: b7c1cc123dd6fadaefbb64b50a0eb2191ce0bab4ffe89aa07ed9e74d4a008776
5
5
  SHA512:
6
- metadata.gz: 02070efdf5658cce58e722464d2932f4908dfa0a848ac50adcc0adda1809566a25cbed61e206a759fd782782e4399639ece9f392d4d631d551c3c36dbb91bbb5
7
- data.tar.gz: 0304f49be57b23352e1e8857b6a2e75b92c1dc71fe23c44151e85409b9bb2bd9a8b5f04f4bf8cf9ac5f7d0066d0129945412e739c5c7903b23d78fae05828f5b
6
+ metadata.gz: bfa175930e17f348f4a2766601b45515b7f8c242526dd07ce37229c8bf4b06a8bdc031cccf0e99a7df1720d59c6c70a520412c93c62a1b2dd1c437a81443973f
7
+ data.tar.gz: 99fce65c67de1d832c0298bb6dfe0c4e75b07131d9c8e2ddaeb11451d573d45da2e46cd74a54e362f9a5f8d5b22f5c4814c4e94aa9e93bb130b69daa2b9cfd27
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Features are simple boolean flags that say whether or not they're enabled, right? Not quite. Features can get quite complicated, as you'll read below in the use cases.
4
4
 
5
- This gem makes reasoning through those complexities much more sane by isolating them all into the `app/features` folder as plain 'ol Ruby objects (POROS), that way your team can reason through the features available in an app much better, test them, and do really complicated stuff when needed.
5
+ This gem makes reasoning through those complexities much more sane by isolating them all into the `app/plans` folder as plain 'ol Ruby objects (POROS), that way your team can reason through the features available in an app much better, test them, and do really complicated stuff when needed.
6
6
 
7
7
  ## Use cases
8
8
 
@@ -32,72 +32,339 @@ $ rails generate superfeature:install
32
32
 
33
33
  Restart your server and it's off to the races!
34
34
 
35
- First thing you'll want to checkout is the `./app/plans/application_plan.rb` file:
35
+ ## Generated Files
36
+
37
+ The generator creates the following structure:
38
+
39
+ ### `app/plans/base.rb`
40
+
41
+ The base plan defines all features with sensible defaults:
36
42
 
37
43
  ```ruby
38
- class ApplicationPlan < Superfeature::Plan
39
- attr_reader :user, :account
44
+ module Plans
45
+ class Base < Superfeature::Plan
46
+ attr_reader :user
47
+
48
+ def initialize(user)
49
+ @user = user
50
+ end
51
+
52
+ # Boolean features - simple on/off flags
53
+ feature def priority_support = disable("Priority support", group: "Support")
54
+ feature def phone_support = disable("Phone support", group: "Support")
55
+
56
+ # Hard limits - strict maximum that cannot be exceeded
57
+ feature def api_calls = hard_limit("API calls", group: "Limits", quantity: user.api_calls_count, maximum: 1000)
40
58
 
41
- def initialize(user)
42
- @user = user
43
- @account = user.account
59
+ # Soft limits - has a soft and hard boundary for overages
60
+ feature def storage_gb = soft_limit("Storage", group: "Limits", quantity: user.storage_used_gb, soft_limit: 100, hard_limit: 150)
61
+
62
+ # Unlimited - no restrictions
63
+ feature def projects = unlimited("Projects", group: "Limits", quantity: user.projects_count)
64
+
65
+ protected
66
+
67
+ def feature(name, **options)
68
+ Features::Base.new(name, **options)
69
+ end
44
70
  end
71
+ end
72
+ ```
73
+
74
+ ### `app/plans/features/base.rb`
45
75
 
46
- def team_size
47
- hard_limit maximum: 0, quantity: account.users.count
76
+ Extends `Superfeature::Feature` with `name` and `group` for display purposes:
77
+
78
+ ```ruby
79
+ module Plans
80
+ module Features
81
+ class Base < Superfeature::Feature
82
+ attr_reader :name, :group
83
+
84
+ def initialize(name = nil, group: nil, **)
85
+ super(**)
86
+ @name = name
87
+ @group = group
88
+ end
89
+ end
48
90
  end
91
+ end
92
+ ```
93
+
94
+ You can add whatever else you want to a feature class, including logic, calculation methods, new types of limits, and more.
95
+
96
+ ### `app/plans/free.rb` and `app/plans/paid.rb`
49
97
 
50
- def moderation
51
- enabled account.moderation_enabled?
98
+ Plans are linked together using `next` and `previous` methods:
99
+
100
+ ```ruby
101
+ module Plans
102
+ class Free < Base
103
+ def name = "Free"
104
+ def price = 0
105
+ def description = "Get started for free"
106
+
107
+ def next = plan Paid
52
108
  end
109
+ end
53
110
 
54
- def support
55
- disabled
111
+ module Plans
112
+ class Paid < Free
113
+ def name = "Paid"
114
+ def price = 9.99
115
+ def description = "Full access to all features"
116
+
117
+ # Override features from Base to enable them
118
+ def priority_support = super.enable
119
+
120
+ def next = nil
121
+ def previous = plan Free
56
122
  end
57
123
  end
58
124
  ```
59
125
 
60
- Here's what it would look like when you add an enterprise plan to the lign up in the ``./app/plans/application_plan.rb`` file.
126
+ The `next` and `previous` methods create a linked list of plans that `Superfeature::Plan::Collection` can traverse.
127
+
128
+ ## Usage
129
+
130
+ ### Setting up User#plan
131
+
132
+ Add a `plan` column to your users table to track which plan they're on:
133
+
134
+ ```ruby
135
+ add_column :users, :plan, :string, default: "free"
136
+ ```
137
+
138
+ Then add a `plan` method to your User model:
61
139
 
62
140
  ```ruby
63
- class EnerprisePlan < ApplicationPlan
64
- def support = enabled
65
- def saml = enabled
141
+ class User < ApplicationRecord
142
+ def plan
143
+ @plan ||= Superfeature::Plan::Collection.new(Plans::Free.new(self)).find(plan_key)
144
+ end
145
+
146
+ def plan_key
147
+ self[:plan]&.to_sym || :free
148
+ end
66
149
  end
67
150
  ```
68
151
 
69
- ## Usage
152
+ Now you can access features directly from the user:
153
+
154
+ ```ruby
155
+ current_user.plan # => Collection wrapping Plans::Free or Plans::Paid
156
+ current_user.plan.priority_support.enabled? # => false
157
+ current_user.plan.upgrades.to_a # => available upgrade plans
158
+ ```
70
159
 
71
- Then you can do things from controllers like:
160
+ ### Checking features in controllers
72
161
 
73
162
  ```ruby
74
163
  class ModerationController < ApplicationController
75
164
  def show
76
- if feature.moderation.enabled?
165
+ if current_plan.moderation.enabled?
77
166
  render "moderation"
78
167
  else
79
- redirect_to moderation_upgrade_path
168
+ redirect_to upgrade_path
80
169
  end
81
170
  end
82
171
 
83
- protected
172
+ private
84
173
 
85
- def plan = ApplicationPlan.new
86
- def feature = plan.moderation
174
+ def current_plan
175
+ @current_plan ||= current_user.plan
176
+ end
177
+ helper_method :current_plan
87
178
  end
88
179
  ```
89
180
 
90
- Or from views:
181
+ ### Checking features in views
91
182
 
92
183
  ```erb
93
184
  <h1>Moderation</h1>
94
- <% if feature.enabled? %>
95
- <p><% render partial: "moderation" %></p>
185
+ <% if current_plan.moderation.enabled? %>
186
+ <%= render partial: "moderation" %>
96
187
  <% else %>
97
188
  <p>Call sales to upgrade to moderation</p>
98
189
  <% end %>
99
190
  ```
100
191
 
192
+ ### Working with Plan::Collection
193
+
194
+ The `Collection` class wraps a plan and provides navigation and enumeration:
195
+
196
+ ```ruby
197
+ # Create a collection starting from any plan
198
+ collection = Superfeature::Plan::Collection.new(Plans::Free.new(current_user))
199
+
200
+ # Find a specific plan by symbol key
201
+ collection.find(:paid) # => Paid plan instance
202
+
203
+ # Find a specific plan by class
204
+ collection.find(Plans::Paid) # => Paid plan instance
205
+
206
+ # Get multiple plans with slice
207
+ collection.slice(:free, :paid) # => Array of matching plans
208
+ collection.slice(Plans::Free, Plans::Paid) # => Also works with classes
209
+
210
+ # Iterate through all plans (includes Enumerable)
211
+ collection.each do |plan|
212
+ puts "#{plan.name}: $#{plan.price}"
213
+ end
214
+
215
+ collection.to_a # All plans as an array
216
+ ```
217
+
218
+ ### Building a pricing page
219
+
220
+ ```ruby
221
+ # In controller
222
+ def index
223
+ @plans = Superfeature::Plan::Collection.new(Plans::Free.new(User.new)).to_a
224
+ end
225
+
226
+ # In view
227
+ <% @plans.each do |plan| %>
228
+ <div class="plan">
229
+ <h2><%= plan.name %></h2>
230
+ <p class="price">$<%= plan.price %>/month</p>
231
+ <p><%= plan.description %></p>
232
+
233
+ <ul>
234
+ <% plan.features.each do |feature| %>
235
+ <li>
236
+ <%= feature.name %>:
237
+ <%= feature.enabled? ? "✓" : "—" %>
238
+ </li>
239
+ <% end %>
240
+ </ul>
241
+
242
+ <%= link_to "Select", plan_path(plan) %>
243
+ </div>
244
+ <% end %>
245
+ ```
246
+
247
+ ### Checking limits
248
+
249
+ ```ruby
250
+ plan = current_user.plan
251
+
252
+ # Hard limits
253
+ if plan.api_calls.exceeded?
254
+ render "api_limit_reached"
255
+ end
256
+
257
+ puts plan.api_calls.quantity # current usage
258
+ puts plan.api_calls.maximum # max allowed
259
+ puts plan.api_calls.remaining # how many left
260
+
261
+ # Boolean features
262
+ plan.priority_support.enabled? # => false
263
+ plan.priority_support.disabled? # => true
264
+ ```
265
+
266
+ ### Preventing inheritance with `exclusively`
267
+
268
+ When plans inherit from each other, methods are inherited too. Sometimes you want a method to only apply to the exact class it's defined in, not subclasses. Use `exclusively`:
269
+
270
+ ```ruby
271
+ module Plans
272
+ class Pro < Basic
273
+ # Only Pro gets this badge, not Enterprise which inherits from Pro
274
+ exclusively def badge = "Most Popular"
275
+ end
276
+ end
277
+
278
+ module Plans
279
+ class Enterprise < Pro
280
+ # badge returns nil here, not "Most Popular"
281
+ end
282
+ end
283
+ ```
284
+
285
+ ## Adding new plans
286
+
287
+ Generate a new plan:
288
+
289
+ ```bash
290
+ $ rails generate superfeature:plan Enterprise
291
+ ```
292
+
293
+ This creates `app/plans/enterprise.rb`:
294
+
295
+ ```ruby
296
+ module Plans
297
+ class Enterprise < Base
298
+ def name = "Enterprise"
299
+ def price = 0
300
+ def description = "Description for Enterprise plan"
301
+
302
+ # Override features from Base to enable them
303
+ # def priority_support = super.enable
304
+
305
+ # Link to adjacent plans for navigation
306
+ # def next = plan NextPlan
307
+ # def previous = plan PreviousPlan
308
+ end
309
+ end
310
+ ```
311
+
312
+ Then wire it into your plan chain by updating `next` and `previous` methods:
313
+
314
+ ```ruby
315
+ # In paid.rb
316
+ def next = plan Enterprise
317
+
318
+ # In enterprise.rb
319
+ def previous = plan Paid
320
+ ```
321
+
322
+ ## Pricing with discounts
323
+
324
+ The `Price` class helps you work with prices and discounts in views:
325
+
326
+ ```ruby
327
+ # Apply discounts
328
+ price = Superfeature::Price.new(100.00)
329
+ price.discount_fixed(20) # => $80.00 (fixed $20 off)
330
+ price.discount_percent(0.25) # => $75.00 (25% off)
331
+ price.discount("25%") # => $75.00 (parses string)
332
+ price.discount("$20") # => $80.00 (parses string)
333
+ price.discount(20) # => $80.00 (numeric = dollars)
334
+
335
+ # Chain discounts
336
+ price = Superfeature::Price.new(100.00)
337
+ .discount_percent(0.10) # 10% off = $90
338
+ .discount_fixed(5.0) # $5 off = $85
339
+
340
+ # Read discount info
341
+ price.amount # => 85.0
342
+ price.original.amount # => 90.0 (previous price in chain)
343
+ price.fixed_discount # => 5.0 (dollars saved from last discount)
344
+ price.percent_discount # => 0.0556 (percent saved from last discount)
345
+ price.discounted? # => true
346
+ ```
347
+
348
+ ### Displaying discounts in views
349
+
350
+ ```erb
351
+ <% if price.discounted? %>
352
+ <span class="original-price line-through">$<%= price.original.to_formatted_s %></span>
353
+ <span class="sale-price">$<%= price.to_formatted_s %></span>
354
+ <span class="savings"><%= (price.percent_discount * 100).to_i %>% off!</span>
355
+ <% else %>
356
+ <span class="price">$<%= price.to_formatted_s %></span>
357
+ <% end %>
358
+ ```
359
+
360
+ ### Custom precision
361
+
362
+ ```ruby
363
+ # Configure precision for currency and percentages
364
+ price = Superfeature::Price.new(99.999, amount_precision: 3, percent_precision: 6)
365
+ price.to_formatted_s # => "99.999"
366
+ ```
367
+
101
368
  ## Comparable libraries
102
369
 
103
370
  There's a few pretty great feature flag libraries that are worth mentioning so you can better evaluate what's right for you.
@@ -126,4 +393,4 @@ Roll-out is similar to Flipper, but is backed soley by Redis.
126
393
 
127
394
  ## License
128
395
 
129
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
396
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,31 @@
1
+ require 'rails/generators'
2
+
3
+ module Superfeature
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def create_plans_directory
9
+ empty_directory "app/plans"
10
+ empty_directory "app/plans/features"
11
+ end
12
+
13
+ def copy_base_plan
14
+ template "base.rb", "app/plans/base.rb"
15
+ end
16
+
17
+ def copy_features_base
18
+ template "features/base.rb", "app/plans/features/base.rb"
19
+ end
20
+
21
+ def copy_plans
22
+ template "free.rb", "app/plans/free.rb"
23
+ template "paid.rb", "app/plans/paid.rb"
24
+ end
25
+
26
+ def create_initializer
27
+ template "plans_initializer.rb", "config/initializers/plans.rb"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,26 @@
1
+ module Plans
2
+ class Base < Superfeature::Plan
3
+ attr_reader :user
4
+
5
+ def initialize(user)
6
+ @user = user
7
+ end
8
+
9
+ # Boolean features - simple on/off flags
10
+ # feature def priority_support = disable("Priority support", group: "Support")
11
+ # feature def phone_support = disable("Phone support", group: "Support")
12
+
13
+ # Hard limits - strict maximum that cannot be exceeded
14
+ # feature def api_calls = hard_limit("API calls", group: "Limits", quantity: user.api_calls_count, maximum: 1000)
15
+
16
+ # Soft limits - has a soft and hard boundary for overages
17
+ # feature def storage_gb = soft_limit("Storage", group: "Limits", quantity: user.storage_used_gb, soft_limit: 100, hard_limit: 150)
18
+
19
+ # Unlimited - no restrictions
20
+ # feature def projects = unlimited("Projects", group: "Limits", quantity: user.projects_count)
21
+
22
+ protected
23
+
24
+ def feature(...) = Features::Base.new(...)
25
+ end
26
+ end
@@ -0,0 +1,13 @@
1
+ module Plans
2
+ module Features
3
+ class Base < Superfeature::Feature
4
+ attr_reader :name, :group
5
+
6
+ def initialize(name = nil, group: nil, **)
7
+ super(**)
8
+ @name = name
9
+ @group = group
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ module Plans
2
+ class Free < Base
3
+ def name = "Free"
4
+ def price = 0
5
+ def description = "Get started for free"
6
+
7
+ def next = plan(Paid)
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ module Plans
2
+ class Paid < Free
3
+ def name = "Paid"
4
+ def price = 9.99
5
+ def description = "Full access to all features"
6
+
7
+ # Override features from Base to enable them
8
+ # def priority_support = super.enable
9
+
10
+ def next = nil
11
+ def previous = plan(Free)
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plans
4
+ module Features
5
+ end
6
+ end
7
+
8
+ Rails.autoloaders.main.push_dir(
9
+ Rails.root.join("app/plans"), namespace: Plans
10
+ )
@@ -0,0 +1,27 @@
1
+ require 'rails/generators'
2
+
3
+ module Superfeature
4
+ module Generators
5
+ class PlanGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def create_plan_file
9
+ template "plan.rb.tt", "app/plans/#{file_name}.rb"
10
+ end
11
+
12
+ private
13
+
14
+ def file_name
15
+ # Remove "_plan" suffix if provided
16
+ name = super
17
+ name.delete_suffix("_plan")
18
+ end
19
+
20
+ def class_name
21
+ # Remove "Plan" suffix if provided
22
+ name = super
23
+ name.delete_suffix("Plan")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,14 @@
1
+ module Plans
2
+ class <%= class_name %> < Base
3
+ def name = "<%= class_name %>"
4
+ def price = 0
5
+ def description = "Description for <%= class_name %> plan"
6
+
7
+ # Override features from Base to enable them
8
+ # def priority_support = super.enable
9
+
10
+ # Link to adjacent plans for navigation
11
+ # def next = plan(NextPlan)
12
+ # def previous = plan(PreviousPlan)
13
+ end
14
+ end
@@ -0,0 +1,41 @@
1
+ require "forwardable"
2
+
3
+ module Superfeature
4
+ class Feature
5
+ extend Forwardable
6
+
7
+ attr_reader :limit
8
+ def_delegators :limit, :enabled?, :disabled?
9
+ def_delegators :limit, :quantity, :maximum, :remaining, :exceeded?
10
+
11
+ def initialize(limit: Limit::Base.new)
12
+ @limit = limit
13
+ end
14
+
15
+ def enable
16
+ @limit = Limit::Boolean.new(enabled: true)
17
+ self
18
+ end
19
+
20
+ def disable
21
+ @limit = Limit::Boolean.new(enabled: false)
22
+ self
23
+ end
24
+
25
+ def boolean?
26
+ limit.is_a?(Limit::Boolean)
27
+ end
28
+
29
+ def hard_limit?
30
+ limit.instance_of?(Limit::Hard)
31
+ end
32
+
33
+ def soft_limit?
34
+ limit.instance_of?(Limit::Soft)
35
+ end
36
+
37
+ def unlimited?
38
+ limit.is_a?(Limit::Unlimited)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,66 @@
1
+ module Superfeature
2
+ module Limit
3
+ class Base
4
+ def enabled?
5
+ false
6
+ end
7
+
8
+ def disabled?
9
+ not enabled?
10
+ end
11
+ end
12
+
13
+ class Hard < Base
14
+ attr_accessor :quantity, :maximum
15
+
16
+ def initialize(quantity: , maximum: )
17
+ @quantity = quantity
18
+ @maximum = maximum
19
+ end
20
+
21
+ def remaining
22
+ maximum - quantity
23
+ end
24
+
25
+ def exceeded?
26
+ quantity > maximum if quantity and maximum
27
+ end
28
+
29
+ def enabled?
30
+ not exceeded?
31
+ end
32
+ end
33
+
34
+ class Soft < Hard
35
+ attr_accessor :soft_limit, :hard_limit
36
+
37
+ def initialize(quantity:, soft_limit:, hard_limit:)
38
+ @quantity = quantity
39
+ @soft_limit = soft_limit
40
+ @hard_limit = hard_limit
41
+ end
42
+
43
+ def maximum
44
+ @soft_limit
45
+ end
46
+ end
47
+
48
+ class Unlimited < Soft
49
+ INFINITY = Float::INFINITY
50
+
51
+ def initialize(quantity: nil, hard_limit: INFINITY, soft_limit: INFINITY, **)
52
+ super(quantity:, hard_limit:, soft_limit:, **)
53
+ end
54
+ end
55
+
56
+ class Boolean < Base
57
+ def initialize(enabled:)
58
+ @enabled = enabled
59
+ end
60
+
61
+ def enabled?
62
+ @enabled
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,71 @@
1
+ module Superfeature
2
+ class Plan
3
+ class Collection
4
+ include Enumerable
5
+
6
+ def initialize(plan)
7
+ @plan = plan
8
+ end
9
+
10
+ def each(&)
11
+ return enum_for(:each) unless block_given?
12
+
13
+ downgrades.each(&)
14
+ yield @plan
15
+ upgrades.each(&)
16
+ end
17
+
18
+ def find(key)
19
+ key = normalize_key(key)
20
+ each.find { |p| p.key.to_s == key }
21
+ end
22
+
23
+ def slice(*keys)
24
+ keys.filter_map { |key| find(key) }
25
+ end
26
+
27
+ private
28
+
29
+ def next_plan
30
+ return nil unless @plan.class.method_defined?(:next, false)
31
+ @plan.next
32
+ end
33
+
34
+ def previous_plan
35
+ return nil unless @plan.class.method_defined?(:previous, false)
36
+ @plan.previous
37
+ end
38
+
39
+ def upgrades
40
+ Enumerator.new do |y|
41
+ node = @plan
42
+ while (node = node.class.method_defined?(:next, false) ? node.next : nil)
43
+ y << node
44
+ end
45
+ end
46
+ end
47
+
48
+ def downgrades
49
+ Enumerator.new do |y|
50
+ node = @plan
51
+ nodes = []
52
+ while (node = node.class.method_defined?(:previous, false) ? node.previous : nil)
53
+ nodes.unshift(node)
54
+ end
55
+ nodes.each { |n| y << n }
56
+ end
57
+ end
58
+
59
+ def normalize_key(key)
60
+ case key
61
+ when Class
62
+ key.name.demodulize.underscore
63
+ when Symbol
64
+ key.to_s
65
+ when String
66
+ key
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,62 @@
1
+ module Superfeature
2
+ class Plan
3
+ class << self
4
+ def features
5
+ ((superclass.respond_to?(:features) ? superclass.features : []) + @features.to_a).uniq
6
+ end
7
+
8
+ def feature(method_name)
9
+ (@features ||= []) << method_name
10
+ method_name
11
+ end
12
+
13
+ def exclusively(method_name)
14
+ klass = self
15
+ original = instance_method(method_name)
16
+ define_method(method_name) do
17
+ original.bind(self).call if instance_of?(klass)
18
+ end
19
+ end
20
+ end
21
+
22
+ def key
23
+ self.class.name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
24
+ end
25
+
26
+ alias_method :to_param, :key
27
+
28
+ def features
29
+ self.class.features.map { |m| send(m) }
30
+ end
31
+
32
+ protected
33
+
34
+ def plan(klass)
35
+ klass.new(user)
36
+ end
37
+
38
+ def feature(*, **, &)
39
+ Feature.new(*, **, &)
40
+ end
41
+
42
+ def enable(*, **)
43
+ feature(*, **, limit: Limit::Boolean.new(enabled: true))
44
+ end
45
+
46
+ def disable(*, **)
47
+ feature(*, **, limit: Limit::Boolean.new(enabled: false))
48
+ end
49
+
50
+ def hard_limit(*, quantity:, maximum:, **)
51
+ feature(*, **, limit: Limit::Hard.new(quantity:, maximum:))
52
+ end
53
+
54
+ def soft_limit(*, quantity:, soft_limit:, hard_limit:, **)
55
+ feature(*, **, limit: Limit::Soft.new(quantity:, soft_limit:, hard_limit:))
56
+ end
57
+
58
+ def unlimited(*, quantity: nil, **)
59
+ feature(*, **, limit: Limit::Unlimited.new(quantity:))
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,97 @@
1
+ module Superfeature
2
+ # Convenience method for creating Price objects.
3
+ # Use Superfeature::Price(100) or after `include Superfeature`, just Price(100)
4
+ def Price(amount, **options)
5
+ Price.new(amount, **options)
6
+ end
7
+ module_function :Price
8
+ public :Price
9
+
10
+ class Price
11
+ DEFAULT_AMOUNT_PRECISION = 2
12
+ DEFAULT_PERCENT_PRECISION = 4
13
+
14
+ attr_reader :amount, :original, :amount_precision, :percent_precision
15
+
16
+ def initialize(amount, original: nil, amount_precision: DEFAULT_AMOUNT_PRECISION, percent_precision: DEFAULT_PERCENT_PRECISION)
17
+ @amount = amount.to_f
18
+ @original = original
19
+ @amount_precision = amount_precision
20
+ @percent_precision = percent_precision
21
+ end
22
+
23
+ # Apply a discount by parsing the input:
24
+ # - "25%" → 25% off
25
+ # - "$20" → $20 off
26
+ # - 20 → $20 off (numeric = always dollars)
27
+ def discount(value)
28
+ case value
29
+ # Matches: "25%", "10.5%", "100 %"
30
+ when /\A(\d+(?:\.\d+)?)\s*%\z/
31
+ discount_percent($1.to_f / 100)
32
+ # Matches: "$20", "$ 20", "20", "19.99", "$19.99"
33
+ when /\A\$?\s*(\d+(?:\.\d+)?)\z/
34
+ discount_fixed($1.to_f)
35
+ when Numeric
36
+ discount_fixed(value)
37
+ else
38
+ raise ArgumentError, "Invalid discount format: #{value.inspect}"
39
+ end
40
+ end
41
+
42
+ # Apply a fixed dollar discount
43
+ def discount_fixed(amount)
44
+ new_amount = ([@amount - amount.to_f, 0].max).round(@amount_precision)
45
+ Price.new(new_amount, original: self, amount_precision: @amount_precision, percent_precision: @percent_precision)
46
+ end
47
+
48
+ # Apply a percentage discount (decimal, e.g., 0.25 for 25%)
49
+ def discount_percent(percent)
50
+ discount_amount = @amount * percent.to_f
51
+ new_amount = (@amount - discount_amount).round(@amount_precision)
52
+ Price.new(new_amount, original: self, amount_precision: @amount_precision, percent_precision: @percent_precision)
53
+ end
54
+
55
+ # Dollars saved from original price
56
+ def fixed_discount
57
+ return 0.0 unless @original
58
+ (@original.amount - @amount).round(@amount_precision)
59
+ end
60
+
61
+ # Percent saved as decimal (e.g., 0.25 for 25%)
62
+ def percent_discount
63
+ return 0.0 unless @original
64
+ return 0.0 if @original.amount.zero?
65
+ ((@original.amount - @amount) / @original.amount).round(@percent_precision)
66
+ end
67
+
68
+ def discounted?
69
+ !@original.nil?
70
+ end
71
+
72
+ def full_price
73
+ @original ? @original.amount : @amount
74
+ end
75
+
76
+ # Format amount as string with configured precision
77
+ def to_formatted_s
78
+ "%.#{@amount_precision}f" % @amount
79
+ end
80
+
81
+ def to_f
82
+ @amount
83
+ end
84
+
85
+ def to_s
86
+ @amount.to_s
87
+ end
88
+
89
+ def inspect
90
+ if discounted?
91
+ "#<Price #{to_formatted_s} (was #{@original.to_formatted_s}, #{(percent_discount * 100).round(1)}% off)>"
92
+ else
93
+ "#<Price #{to_formatted_s}>"
94
+ end
95
+ end
96
+ end
97
+ end
@@ -1,3 +1,3 @@
1
1
  module Superfeature
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end
data/lib/superfeature.rb CHANGED
@@ -1,123 +1,10 @@
1
1
  require "superfeature/version"
2
- require "superfeature/engine"
2
+ require "superfeature/engine" if defined?(Rails)
3
+ require "superfeature/limit"
4
+ require "superfeature/feature"
5
+ require "superfeature/plan"
6
+ require "superfeature/plan/collection"
7
+ require "superfeature/price"
3
8
 
4
9
  module Superfeature
5
- def self.plan(&)
6
- Class.new(Superfeature::Plan, &)
7
- end
8
-
9
- class Feature
10
- attr_reader :plan, :limit, :name
11
- delegate :enabled?, :disabled?, to: :limit
12
- delegate :upgrade, :downgrade, to: :plan
13
-
14
- def initialize(plan:, name:, limit: Limit::Base.new)
15
- @plan = plan
16
- @limit = limit
17
- @name = name
18
- end
19
- end
20
-
21
- module Limit
22
- class Base
23
- def enabled?
24
- false
25
- end
26
-
27
- def disabled?
28
- not enabled?
29
- end
30
- end
31
-
32
- class Hard < Base
33
- attr_accessor :quantity, :maximum
34
-
35
- def initialize(quantity: , maximum: )
36
- @quantity = quantity
37
- @maximum = maximum
38
- end
39
-
40
- def remaining
41
- maximum - quantity
42
- end
43
-
44
- def exceeded?
45
- quantity > maximum if quantity and maximum
46
- end
47
-
48
- def enabled?
49
- not exceeded?
50
- end
51
- end
52
-
53
- class Soft < Hard
54
- attr_accessor :quantity, :soft_limit, :hard_limit
55
-
56
- def initialize(quantity:, soft_limit:, hard_limit:)
57
- @quantity = quantity
58
- @soft_limit = soft_limit
59
- @hard_limit = hard_limit
60
- end
61
-
62
- def maximum
63
- @soft_limit
64
- end
65
- end
66
-
67
- # Unlimited is treated like a Soft, initialized with infinity values.
68
- # It is recommended to set a `soft_limit` value based on the technical limitations
69
- # of your application unless you're running a theoritcal Turing Machine.
70
- #
71
- # See https://en.wikipedia.org/wiki/Turing_machine for details.
72
- class Unlimited < Soft
73
- INFINITY = Float::INFINITY
74
-
75
- def initialize(quantity: nil, hard_limit: INFINITY, soft_limit: INFINITY, **)
76
- super(quantity:, hard_limit:, soft_limit:, **)
77
- end
78
- end
79
-
80
- class Boolean < Base
81
- def initialize(enabled:)
82
- @enabled = enabled
83
- end
84
-
85
- def enabled?
86
- @enabled
87
- end
88
- end
89
- end
90
-
91
- class Plan
92
- def upgrade
93
- end
94
-
95
- def downgrade
96
- end
97
-
98
- protected
99
- def hard_limit(**)
100
- Limit::Hard.new(**)
101
- end
102
-
103
- def soft_limit(**)
104
- Limit::Soft.new(**)
105
- end
106
-
107
- def unlimited(**)
108
- Limit::Unlimited.new(**)
109
- end
110
-
111
- def enabled(value = true, **)
112
- Limit::Boolean.new enabled: value, **
113
- end
114
-
115
- def disabled(value = true)
116
- enabled !value
117
- end
118
-
119
- def feature(name, **)
120
- Feature.new(plan: self, name:, **)
121
- end
122
- end
123
10
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: superfeature
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brad Gessler
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-10-11 00:00:00.000000000 Z
10
+ date: 2026-01-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -44,8 +44,21 @@ files:
44
44
  - app/models/superfeature/application_record.rb
45
45
  - app/views/layouts/superfeature/application.html.erb
46
46
  - config/routes.rb
47
+ - lib/generators/superfeature/install/install_generator.rb
48
+ - lib/generators/superfeature/install/templates/base.rb
49
+ - lib/generators/superfeature/install/templates/features/base.rb
50
+ - lib/generators/superfeature/install/templates/free.rb
51
+ - lib/generators/superfeature/install/templates/paid.rb
52
+ - lib/generators/superfeature/install/templates/plans_initializer.rb
53
+ - lib/generators/superfeature/plan/plan_generator.rb
54
+ - lib/generators/superfeature/plan/templates/plan.rb.tt
47
55
  - lib/superfeature.rb
48
56
  - lib/superfeature/engine.rb
57
+ - lib/superfeature/feature.rb
58
+ - lib/superfeature/limit.rb
59
+ - lib/superfeature/plan.rb
60
+ - lib/superfeature/plan/collection.rb
61
+ - lib/superfeature/price.rb
49
62
  - lib/superfeature/version.rb
50
63
  - lib/tasks/superfeature_tasks.rake
51
64
  homepage: https://github.com/rubymonolith/superfeature