superfeature 0.1.7 → 0.1.8

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: 49aa8d4e0fec4908fb0ef75f2775154ed4824e79565ffde44f39ccbf723f0e7f
4
- data.tar.gz: e9e7e20b7eb9c9ac39baeeb68d1311ccad33b843e88270b0cefd7fc023ac251a
3
+ metadata.gz: a58671cf3adcbfe8c43bf36cfcac421cfc5a43bf4b68e9cbf894a5f2e1e72e48
4
+ data.tar.gz: '08dcb7627301e5319c0a9ba9210dba96648884384ac28e08a511779e837b3d50'
5
5
  SHA512:
6
- metadata.gz: 865e638f07b0667a2bfe1eec952877489f75fa8e5dfdf891289fa559de9dae9dab421d71929962bca15c12525ecb8626a8daaa46223803302d455621db1d15c5
7
- data.tar.gz: '08b0e605470738e1be904119b2e1db03fb1b413b386b3cc19ebfb53e0e710f2e7db23d7f0e276302d1a3b9b596ae85e99c3f891f68e3c650a75f489755882519'
6
+ metadata.gz: 5168c95720797f8eeb41cf7328d3f9af24bc91c7a335522c7043e6c5683ec45db6668de0d1bc8e43bb30ede7dbb3954406371e0f504c95ff7d4ef240aa4db78c
7
+ data.tar.gz: a1f74279a8a32deec8fdddec2cdaad4018292e9e9b0a0b8aa61b3bd8a6b9ab360da78cf60387057d7b5a5b3b35af2d5352b3fdeac0cee02f0359278227ed6173
data/README.md CHANGED
@@ -32,6 +32,415 @@ $ rails generate superfeature:install
32
32
 
33
33
  Restart your server and it's off to the races!
34
34
 
35
+ ## Quick Start
36
+
37
+ ### Plans
38
+
39
+ A plan is a Ruby class that defines what features are available:
40
+
41
+ ```ruby
42
+ module Plans
43
+ class Free < Superfeature::Plan
44
+ def name = "Free"
45
+ def description = "Get started for free"
46
+ end
47
+ end
48
+ ```
49
+
50
+ ### Features
51
+
52
+ Features are methods that return enabled/disabled states:
53
+
54
+ ```ruby
55
+ module Plans
56
+ class Free < Superfeature::Plan
57
+ feature def priority_support = disable("Priority support")
58
+ feature def api_access = enable("API access")
59
+ end
60
+ end
61
+ ```
62
+
63
+ Check features in your app:
64
+
65
+ ```ruby
66
+ plan = Plans::Free.new(current_user)
67
+ plan.priority_support.enabled? # => false
68
+ plan.api_access.enabled? # => true
69
+ ```
70
+
71
+ ### Limits
72
+
73
+ Features can also be limits with quantities:
74
+
75
+ ```ruby
76
+ module Plans
77
+ class Free < Superfeature::Plan
78
+ feature def projects = hard_limit("Projects", quantity: user.projects_count, maximum: 5)
79
+ feature def storage_gb = soft_limit("Storage", quantity: user.storage_gb, soft_limit: 1, hard_limit: 2)
80
+ end
81
+ end
82
+ ```
83
+
84
+ Check limits:
85
+
86
+ ```ruby
87
+ plan.projects.exceeded? # => true if over 5
88
+ plan.projects.remaining # => how many left
89
+ plan.storage_gb.warning? # => true if between soft and hard limit
90
+ ```
91
+
92
+ ### Plan Inheritance
93
+
94
+ Plans inherit from each other. Override features to change them:
95
+
96
+ ```ruby
97
+ module Plans
98
+ class Pro < Free
99
+ def name = "Pro"
100
+ def description = "For professionals"
101
+
102
+ # Enable what was disabled in Free
103
+ def priority_support = super.enable
104
+
105
+ # Increase limits
106
+ def projects = hard_limit("Projects", quantity: user.projects_count, maximum: 100)
107
+ end
108
+ end
109
+ ```
110
+
111
+ ### Navigation Between Plans
112
+
113
+ Link plans together with `next` and `previous`:
114
+
115
+ ```ruby
116
+ module Plans
117
+ class Free < Superfeature::Plan
118
+ def next = plan Pro
119
+ end
120
+
121
+ class Pro < Free
122
+ def previous = plan Free
123
+ def next = plan Enterprise
124
+ end
125
+
126
+ class Enterprise < Pro
127
+ def previous = plan Pro
128
+ end
129
+ end
130
+ ```
131
+
132
+ ## Pricing
133
+
134
+ The `Price` class handles monetary values with precision using `BigDecimal` internally:
135
+
136
+ ```ruby
137
+ price = Superfeature::Price.new(49.99)
138
+ price.amount # => BigDecimal("49.99")
139
+ price.to_f # => 49.99
140
+ price.to_i # => 49
141
+ ```
142
+
143
+ ### Adding Price to Plans
144
+
145
+ ```ruby
146
+ module Plans
147
+ class Free < Superfeature::Plan
148
+ def price = Price(0)
149
+ end
150
+
151
+ class Pro < Free
152
+ def price = Price(29)
153
+ end
154
+
155
+ class Enterprise < Pro
156
+ def price = Price(99)
157
+ end
158
+ end
159
+ ```
160
+
161
+ ### Formatting Prices
162
+
163
+ ```ruby
164
+ price = Price(29)
165
+ price.to_formatted_s # => "29.00"
166
+ price.to_formatted_s(decimals: 0) # => "29"
167
+ ```
168
+
169
+ ## Discounts
170
+
171
+ Apply discounts to prices:
172
+
173
+ ```ruby
174
+ price = Price(100)
175
+
176
+ # Fixed dollar amount off
177
+ price.discount_fixed(20).amount # => 80.0
178
+
179
+ # Percentage off (25 = 25%)
180
+ price.discount_percent(25).amount # => 75.0
181
+
182
+ # Set a target price directly
183
+ price.discount_to(79).amount # => 79.0
184
+ ```
185
+
186
+ ### Discount Strings
187
+
188
+ Parse discount strings naturally:
189
+
190
+ ```ruby
191
+ price = Price(100)
192
+ price.apply_discount("20%").amount # => 80.0
193
+ price.apply_discount("$15").amount # => 85.0
194
+ price.apply_discount(10).amount # => 90.0 (numeric = dollars off)
195
+ ```
196
+
197
+ ### Reading Discount Info
198
+
199
+ After applying a discount, access the details:
200
+
201
+ ```ruby
202
+ price = Price(100).apply_discount("25%")
203
+
204
+ price.amount # => 75.0
205
+ price.discounted? # => true
206
+ price.original.amount # => 100.0
207
+
208
+ price.discount.fixed # => 25.0 (dollars saved this step)
209
+ price.discount.percent # => 25.0 (percent of original this step)
210
+ price.discount.to_fixed_s # => "25.00"
211
+ price.discount.to_percent_s # => "25%"
212
+ price.discount.to_formatted_s # => "25%" (natural format)
213
+ ```
214
+
215
+ ### Cumulative Savings
216
+
217
+ When chaining multiple discounts, use `savings` to get the total discount from the original price:
218
+
219
+ ```ruby
220
+ price = Price(100).apply_discount("20%").apply_discount("$10")
221
+ # 100 -> 80 -> 70
222
+
223
+ price.discount.fixed # => 10.0 (last step only)
224
+ price.discount.percent # => 10.0 (last step as % of original)
225
+
226
+ price.savings.fixed # => 30.0 (total saved from original)
227
+ price.savings.percent # => 30.0 (total % off original)
228
+ price.savings.to_fixed_s # => "30.00"
229
+ price.savings.to_percent_s # => "30%"
230
+ ```
231
+
232
+ ### Discount Objects
233
+
234
+ For reusable discounts, create `Discount` objects:
235
+
236
+ ```ruby
237
+ include Superfeature
238
+
239
+ summer_sale = Discount::Percent.new(20)
240
+ loyalty = Discount::Fixed.new(10)
241
+
242
+ Price(100).apply_discount(summer_sale).amount # => 80.0
243
+ Price(100).apply_discount(loyalty).amount # => 90.0
244
+ ```
245
+
246
+ Apply multiple discounts:
247
+
248
+ ```ruby
249
+ Price(100).apply_discount(
250
+ Discount::Fixed.new(10), # $10 off first
251
+ Discount::Percent.new(20) # then 20% off
252
+ ).amount # => 72.0 (100 - 10 = 90, then 90 * 0.8 = 72)
253
+ ```
254
+
255
+ ### Rounding to Endings
256
+
257
+ Round prices to specific endings like $9.99 or $49:
258
+
259
+ ```ruby
260
+ price = Price(50)
261
+
262
+ price.round(9) # => Price(49) - nearest ending in 9
263
+ price.round_up(9) # => Price(59) - round up to ending in 9
264
+ price.round_down(9) # => Price(49) - round down to ending in 9
265
+
266
+ price.round(0.99) # => Price(49.99) - nearest ending in .99
267
+ ```
268
+
269
+ ### Price Chains
270
+
271
+ Chain operations directly on prices:
272
+
273
+ ```ruby
274
+ Price(100)
275
+ .discount_percent(20) # => Price(80)
276
+ .discount_fixed(10) # => Price(70)
277
+ .round_up(9) # => Price(79)
278
+ ```
279
+
280
+ Each step returns a new `Price` with a reference to the previous price, so you can walk back the chain:
281
+
282
+ ```ruby
283
+ final = Price(100).discount_percent(20).round_up(9)
284
+
285
+ final.amount # => 89
286
+ final.previous.amount # => 80
287
+ final.original.amount # => 100
288
+ final.discounted? # => true
289
+ ```
290
+
291
+ ### Custom Discount Sources
292
+
293
+ Any object can be a discount if it implements `to_discount`:
294
+
295
+ ```ruby
296
+ class Coupon < ApplicationRecord
297
+ def to_discount
298
+ Superfeature::Discount::Percent.new(percent_off)
299
+ end
300
+ end
301
+
302
+ coupon = Coupon.find_by(code: "SAVE20")
303
+ price = Price(100).apply_discount(coupon)
304
+ price.amount # => 80.0
305
+ ```
306
+
307
+ ## Building a Pricing Table
308
+
309
+ Here's how to put it all together for a pricing page.
310
+
311
+ ### Define Your Plans
312
+
313
+ ```ruby
314
+ module Plans
315
+ class Base < Superfeature::Plan
316
+ attr_reader :user
317
+
318
+ def initialize(user)
319
+ @user = user
320
+ end
321
+
322
+ feature def projects = hard_limit("Projects", quantity: user.projects_count, maximum: 3)
323
+ feature def api_access = disable("API access")
324
+ feature def priority_support = disable("Priority support")
325
+ end
326
+
327
+ class Free < Base
328
+ def name = "Free"
329
+ def price = Price(0)
330
+ def next = plan Pro
331
+ end
332
+
333
+ class Pro < Free
334
+ def name = "Pro"
335
+ def price = Price(29)
336
+
337
+ def projects = hard_limit("Projects", quantity: user.projects_count, maximum: 100)
338
+ def api_access = super.enable
339
+
340
+ def previous = plan Free
341
+ def next = plan Enterprise
342
+ end
343
+
344
+ class Enterprise < Pro
345
+ def name = "Enterprise"
346
+ def price = Price(99)
347
+
348
+ def projects = unlimited("Projects", quantity: user.projects_count)
349
+ def api_access = super.enable
350
+ def priority_support = super.enable
351
+
352
+ def previous = plan Pro
353
+ end
354
+ end
355
+ ```
356
+
357
+ ### Add a Promotion
358
+
359
+ ```ruby
360
+ class Promotion
361
+ attr_reader :name, :percent_off
362
+
363
+ def initialize(name:, percent_off:)
364
+ @name = name
365
+ @percent_off = percent_off
366
+ end
367
+
368
+ def to_discount
369
+ Superfeature::Discount::Percent.new(@percent_off)
370
+ end
371
+ end
372
+ ```
373
+
374
+ ### Controller
375
+
376
+ ```ruby
377
+ class PricingController < ApplicationController
378
+ def index
379
+ @plans = Superfeature::Plan::Collection.new(Plans::Free.new(User.new)).to_a
380
+ @promo = Promotion.new(name: "Launch Special", percent_off: 20)
381
+ end
382
+ end
383
+ ```
384
+
385
+ ### View
386
+
387
+ ```erb
388
+ <h1>Pricing</h1>
389
+
390
+ <% if @promo %>
391
+ <div class="promo-banner">
392
+ <%= @promo.name %>: Save <%= @promo.percent_off %>% on all plans!
393
+ </div>
394
+ <% end %>
395
+
396
+ <div class="pricing-grid">
397
+ <% @plans.each do |plan| %>
398
+ <div class="plan-card">
399
+ <h2><%= plan.name %></h2>
400
+
401
+ <% price = plan.price %>
402
+ <% if @promo && price.positive? %>
403
+ <% discounted = price.apply_discount(@promo) %>
404
+ <p class="price">
405
+ <span class="original">$<%= price.to_formatted_s(decimals: 0) %></span>
406
+ <span class="sale">$<%= discounted.to_formatted_s(decimals: 0) %></span>
407
+ <span class="savings">Save <%= discounted.discount.to_percent_s %></span>
408
+ </p>
409
+ <% else %>
410
+ <p class="price">
411
+ <% if price.free? %>
412
+ Free
413
+ <% else %>
414
+ $<%= price.to_formatted_s(decimals: 0) %>/mo
415
+ <% end %>
416
+ </p>
417
+ <% end %>
418
+
419
+ <ul class="features">
420
+ <% plan.features.each do |feature| %>
421
+ <li>
422
+ <% if feature.enabled? %>
423
+ <span class="check">✓</span>
424
+ <% else %>
425
+ <span class="x">✗</span>
426
+ <% end %>
427
+ <%= feature.name %>
428
+ </li>
429
+ <% end %>
430
+ </ul>
431
+
432
+ <%= link_to "Choose #{plan.name}", subscribe_path(plan: plan.key), class: "button" %>
433
+ </div>
434
+ <% end %>
435
+ </div>
436
+ ```
437
+
438
+ This renders a pricing table with:
439
+ - Original and discounted prices when a promotion is active
440
+ - Feature list with checkmarks
441
+ - "Free" label for zero-price plans
442
+ - Savings percentage from the discount
443
+
35
444
  ## Generated Files
36
445
 
37
446
  The generator creates the following structure:
@@ -215,35 +624,6 @@ end
215
624
  collection.to_a # All plans as an array
216
625
  ```
217
626
 
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
627
  ### Checking limits
248
628
 
249
629
  ```ruby
@@ -323,103 +703,63 @@ def next = plan Enterprise
323
703
  def previous = plan Paid
324
704
  ```
325
705
 
326
- ## Pricing with discounts
706
+ ## Price Reference
327
707
 
328
- The `Price` class helps you work with prices and discounts in views:
708
+ ### Creating Prices
329
709
 
330
710
  ```ruby
331
- # Apply discounts
332
- price = Superfeature::Price.new(100.00)
333
- price.discount_fixed(20) # => $80.00 (fixed $20 off)
334
- price.discount_percent(0.25) # => $75.00 (25% off)
335
- price.discount("25%") # => $75.00 (parses string)
336
- price.discount("$20") # => $80.00 (parses string)
337
- price.discount(20) # => $80.00 (numeric = dollars)
338
- price.to(80) # => $80.00 (set target price directly)
339
-
340
- # Chain discounts
341
- price = Superfeature::Price.new(100.00)
342
- .discount_percent(0.10) # 10% off = $90
343
- .discount_fixed(5.0) # $5 off = $85
344
-
345
- # Read discount info
346
- price.amount # => 85.0
347
- price.original.amount # => 90.0 (previous price in chain)
348
- price.fixed_discount # => 5.0 (dollars saved from last discount)
349
- price.percent_discount # => 0.0556 (percent saved from last discount)
350
- price.discounted? # => true
711
+ price = Price(49.99) # convenience method
712
+ price = Price.new(49.99) # standard constructor
351
713
  ```
352
714
 
353
- ### Displaying discounts in views
354
-
355
- ```erb
356
- <% if price.discounted? %>
357
- <span class="original-price line-through">$<%= price.original.to_formatted_s %></span>
358
- <span class="sale-price">$<%= price.to_formatted_s %></span>
359
- <span class="savings"><%= (price.percent_discount * 100).to_i %>% off!</span>
360
- <% else %>
361
- <span class="price">$<%= price.to_formatted_s %></span>
362
- <% end %>
363
- ```
364
-
365
- ### Custom precision
715
+ In Rails, you can also use core extensions:
366
716
 
367
717
  ```ruby
368
- # Configure precision for currency and percentages
369
- price = Superfeature::Price.new(99.999, amount_precision: 3, percent_precision: 6)
370
- price.to_formatted_s # => "99.999"
718
+ 100.discounted_by(20.percent_off) # => Price(80)
719
+ 100.discounted_by(20) # => Price(80)
720
+ 100.to_price # => Price(100)
721
+ "$49.99".to_price # => Price(49.99)
371
722
  ```
372
723
 
373
- ### Discount objects
724
+ Outside of Rails, opt-in with `require "superfeature/core_ext"`.
374
725
 
375
- For more control, use typed Discount objects directly:
726
+ ### Conversions
376
727
 
377
728
  ```ruby
378
- # Fixed dollar discount
379
- Superfeature::Discount::Fixed(20) # $20 off
380
-
381
- # Percentage discount
382
- Superfeature::Discount::Percent(25) # 25% off
383
- Superfeature::Discount::Percent(10.5) # 10.5% off
384
-
385
- # Bundle multiple discounts (note: splat args, not array)
386
- Superfeature::Discount::Bundle(
387
- Superfeature::Discount::Fixed(5),
388
- Superfeature::Discount::Percent(20)
389
- )
729
+ price.to_f # => 49.99 (Float)
730
+ price.to_i # => 49 (Integer)
731
+ price.to_d # => BigDecimal("49.99")
732
+ price.to_s # => "49" or "49.99" (display-friendly, omits .00)
733
+ price.to_formatted_s(decimals: 2) # => "49.99" (consistent decimals)
390
734
  ```
391
735
 
392
- Or with `include Superfeature`:
736
+ ### Comparisons
393
737
 
394
738
  ```ruby
395
- include Superfeature
396
-
397
- Price(100).discount(Percent(25))
398
- Price(100).discount(Fixed(20))
399
- Price(100).discount(Bundle(Fixed(5), Percent(20)))
739
+ Price(100) > Price(50) # => true
740
+ Price(100) == 100 # => true
741
+ Price(100) < 200 # => true
400
742
  ```
401
743
 
402
- ### Custom discount sources with `to_discount`
403
-
404
- Any object can be passed to `Price#discount` if it implements `to_discount`:
744
+ ### Math
405
745
 
406
746
  ```ruby
407
- class Promotion < ApplicationRecord
408
- def to_discount
409
- Superfeature::Discount::Percent.new(percent_off)
410
- end
411
- end
747
+ Price(100) + 20 # => Price(120)
748
+ Price(100) - 20 # => Price(80)
749
+ Price(100) * 2 # => Price(200)
750
+ Price(100) / 4 # => Price(25)
751
+ 10 + Price(5) # => Price(15)
752
+ ```
412
753
 
413
- promo = Promotion.find(1)
414
- price = Superfeature::Price.new(100).discount(promo)
754
+ ### Queries
415
755
 
416
- price.amount # => discounted amount
417
- price.discount_source # => the Promotion instance
418
- price.discount_source.name # => access promotion metadata
756
+ ```ruby
757
+ Price(0).zero? # => true
758
+ Price(0).free? # => true (alias)
759
+ Price(100).positive? # => true
760
+ Price(100).paid? # => true (alias)
419
761
  ```
420
762
 
421
- This allows rich domain objects (promotions, deals, coupons) to be used directly with Price while preserving access to their metadata via `discount_source`.
422
-
423
763
  ## Comparable libraries
424
764
 
425
765
  There's a few pretty great feature flag libraries that are worth mentioning so you can better evaluate what's right for you.
@@ -0,0 +1,19 @@
1
+ # Adds pricing extensions to Numeric (Integer, Float, BigDecimal)
2
+ #
3
+ # 100.discounted_by(20.percent_off) # => Price(80)
4
+ # 100.discounted_by(20) # => Price(80)
5
+ # 100.to_price # => Price(100)
6
+ #
7
+ class Numeric
8
+ def to_price(**)
9
+ Superfeature::Price.new(self, **)
10
+ end
11
+
12
+ def percent_off
13
+ Superfeature::Discount::Percent.new(self)
14
+ end
15
+
16
+ def discounted_by(...)
17
+ to_price.apply_discount(...)
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ # Adds pricing extensions to String
2
+ #
3
+ # "49.99".to_price # => Price(49.99)
4
+ # "$100".to_price # => Price(100)
5
+ #
6
+ class String
7
+ def to_price(**options)
8
+ # Strip leading $ and whitespace
9
+ value = self.gsub(/\A\$?\s*/, '')
10
+ Superfeature::Price.new(value, **options)
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # Load all core extensions for Price
2
+ #
3
+ # These are opt-in. To use them:
4
+ #
5
+ # require "superfeature/core_ext"
6
+ #
7
+ # 10.to_price # => Price(10)
8
+ # "49.99".to_price # => Price(49.99)
9
+ #
10
+ require "superfeature"
11
+ require "superfeature/core_ext/numeric"
12
+ require "superfeature/core_ext/string"