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 +4 -4
- data/README.md +442 -102
- data/lib/superfeature/core_ext/numeric.rb +19 -0
- data/lib/superfeature/core_ext/string.rb +12 -0
- data/lib/superfeature/core_ext.rb +12 -0
- data/lib/superfeature/discount.rb +171 -11
- data/lib/superfeature/engine.rb +4 -0
- data/lib/superfeature/plan/collection.rb +14 -14
- data/lib/superfeature/plan.rb +2 -2
- data/lib/superfeature/price.rb +316 -61
- data/lib/superfeature/round.rb +83 -0
- data/lib/superfeature/version.rb +1 -1
- data/lib/superfeature.rb +20 -7
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a58671cf3adcbfe8c43bf36cfcac421cfc5a43bf4b68e9cbf894a5f2e1e72e48
|
|
4
|
+
data.tar.gz: '08dcb7627301e5319c0a9ba9210dba96648884384ac28e08a511779e837b3d50'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
##
|
|
706
|
+
## Price Reference
|
|
327
707
|
|
|
328
|
-
|
|
708
|
+
### Creating Prices
|
|
329
709
|
|
|
330
710
|
```ruby
|
|
331
|
-
#
|
|
332
|
-
price =
|
|
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
|
-
|
|
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
|
-
#
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
724
|
+
Outside of Rails, opt-in with `require "superfeature/core_ext"`.
|
|
374
725
|
|
|
375
|
-
|
|
726
|
+
### Conversions
|
|
376
727
|
|
|
377
728
|
```ruby
|
|
378
|
-
#
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
#
|
|
382
|
-
|
|
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
|
-
|
|
736
|
+
### Comparisons
|
|
393
737
|
|
|
394
738
|
```ruby
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
Price(100)
|
|
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
|
-
###
|
|
403
|
-
|
|
404
|
-
Any object can be passed to `Price#discount` if it implements `to_discount`:
|
|
744
|
+
### Math
|
|
405
745
|
|
|
406
746
|
```ruby
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
414
|
-
price = Superfeature::Price.new(100).discount(promo)
|
|
754
|
+
### Queries
|
|
415
755
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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"
|