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 +4 -4
- data/README.md +295 -28
- data/lib/generators/superfeature/install/install_generator.rb +31 -0
- data/lib/generators/superfeature/install/templates/base.rb +26 -0
- data/lib/generators/superfeature/install/templates/features/base.rb +13 -0
- data/lib/generators/superfeature/install/templates/free.rb +9 -0
- data/lib/generators/superfeature/install/templates/paid.rb +13 -0
- data/lib/generators/superfeature/install/templates/plans_initializer.rb +10 -0
- data/lib/generators/superfeature/plan/plan_generator.rb +27 -0
- data/lib/generators/superfeature/plan/templates/plan.rb.tt +14 -0
- data/lib/superfeature/feature.rb +41 -0
- data/lib/superfeature/limit.rb +66 -0
- data/lib/superfeature/plan/collection.rb +71 -0
- data/lib/superfeature/plan.rb +62 -0
- data/lib/superfeature/price.rb +97 -0
- data/lib/superfeature/version.rb +1 -1
- data/lib/superfeature.rb +6 -119
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea2cc79603e2a605076a000f1c99224a460e812ca128b1685c9b2ccf84189de9
|
|
4
|
+
data.tar.gz: b7c1cc123dd6fadaefbb64b50a0eb2191ce0bab4ffe89aa07ed9e74d4a008776
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
def
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
+
### Checking features in controllers
|
|
72
161
|
|
|
73
162
|
```ruby
|
|
74
163
|
class ModerationController < ApplicationController
|
|
75
164
|
def show
|
|
76
|
-
if
|
|
165
|
+
if current_plan.moderation.enabled?
|
|
77
166
|
render "moderation"
|
|
78
167
|
else
|
|
79
|
-
redirect_to
|
|
168
|
+
redirect_to upgrade_path
|
|
80
169
|
end
|
|
81
170
|
end
|
|
82
171
|
|
|
83
|
-
|
|
172
|
+
private
|
|
84
173
|
|
|
85
|
-
def
|
|
86
|
-
|
|
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
|
-
|
|
181
|
+
### Checking features in views
|
|
91
182
|
|
|
92
183
|
```erb
|
|
93
184
|
<h1>Moderation</h1>
|
|
94
|
-
<% if
|
|
95
|
-
|
|
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
|
+
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,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
|
data/lib/superfeature/version.rb
CHANGED
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.
|
|
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:
|
|
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
|