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
data/lib/superfeature/price.rb
CHANGED
|
@@ -1,24 +1,42 @@
|
|
|
1
|
-
|
|
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
|
|
1
|
+
require 'bigdecimal'
|
|
9
2
|
|
|
3
|
+
module Superfeature
|
|
4
|
+
# Immutable price object with discount support. Uses BigDecimal internally
|
|
5
|
+
# to avoid floating-point precision errors.
|
|
6
|
+
#
|
|
7
|
+
# price = Price.new(49.99)
|
|
8
|
+
# discounted = price.apply_discount("20%")
|
|
9
|
+
# discounted.amount # => 39.99
|
|
10
|
+
# discounted.discount.percent # => 20.0
|
|
11
|
+
#
|
|
10
12
|
class Price
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
include Comparable
|
|
14
|
+
|
|
15
|
+
attr_reader :amount, :previous, :range
|
|
16
|
+
|
|
17
|
+
# Creates a new Price.
|
|
18
|
+
# - amount: the price value (converted to BigDecimal)
|
|
19
|
+
# - previous: the previous price in a discount chain
|
|
20
|
+
# - discount: the applied Discount::Applied object
|
|
21
|
+
# - range: clamp values to this range (default 0.., use nil for no clamping)
|
|
22
|
+
def initialize(amount, previous: nil, discount: nil, range: 0..)
|
|
23
|
+
@amount = clamp_to_range(to_decimal(amount), range)
|
|
24
|
+
@previous = previous
|
|
25
|
+
@discount = discount
|
|
26
|
+
@range = range
|
|
27
|
+
end
|
|
13
28
|
|
|
14
|
-
|
|
29
|
+
def price = self
|
|
15
30
|
|
|
16
|
-
def
|
|
17
|
-
@
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
def discount
|
|
32
|
+
@discount || Discount::NONE
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def savings
|
|
36
|
+
original_amount = original.amount
|
|
37
|
+
fixed = original_amount - @amount
|
|
38
|
+
percent = original_amount.zero? ? BigDecimal("0") : (fixed / original_amount * 100)
|
|
39
|
+
Discount::Savings.new(fixed:, percent:)
|
|
22
40
|
end
|
|
23
41
|
|
|
24
42
|
# Apply a discount from various sources:
|
|
@@ -27,97 +45,334 @@ module Superfeature
|
|
|
27
45
|
# - Discount object: Discount::Percent.new(25) → 25% off
|
|
28
46
|
# - Any object responding to to_discount
|
|
29
47
|
# - nil: no discount, returns self
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
def apply_discount(*discounts)
|
|
49
|
+
discount, *remaining = discounts.flatten
|
|
50
|
+
|
|
51
|
+
if discount.present?
|
|
52
|
+
coerced = coerce_discount(discount)
|
|
53
|
+
discounted = coerced.apply(@amount)
|
|
54
|
+
original_amount = original.amount
|
|
55
|
+
fixed = @amount - discounted
|
|
56
|
+
percent = original_amount.zero? ? BigDecimal("0") : (fixed / original_amount * 100)
|
|
35
57
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
58
|
+
applied = Discount::Applied.new(coerced, fixed:, percent:)
|
|
59
|
+
|
|
60
|
+
build_price(discounted, previous: self, discount: applied).apply_discount(*remaining)
|
|
61
|
+
else
|
|
62
|
+
price
|
|
63
|
+
end
|
|
42
64
|
end
|
|
43
65
|
|
|
44
66
|
# Apply a fixed dollar discount
|
|
45
67
|
def discount_fixed(amount)
|
|
46
|
-
|
|
68
|
+
apply_discount Discount::Fixed.new to_decimal(amount)
|
|
47
69
|
end
|
|
48
70
|
|
|
49
|
-
# Set the price to a specific amount
|
|
50
|
-
# Price(300).
|
|
51
|
-
def
|
|
52
|
-
|
|
71
|
+
# Set the price to a specific amount
|
|
72
|
+
# Price(300).discount_to(200) is equivalent to Price(300).discount_fixed(100)
|
|
73
|
+
def discount_to(new_amount)
|
|
74
|
+
diff = @amount - to_decimal(new_amount)
|
|
75
|
+
discount_fixed diff.positive? ? diff : 0
|
|
53
76
|
end
|
|
54
77
|
|
|
55
|
-
# Apply a percentage discount (
|
|
78
|
+
# Apply a percentage discount (e.g., 50 for 50% off)
|
|
56
79
|
def discount_percent(percent)
|
|
57
|
-
|
|
80
|
+
apply_discount Discount::Percent.new(percent)
|
|
58
81
|
end
|
|
59
82
|
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
83
|
+
# Round price to a specified ending.
|
|
84
|
+
# Defaults to nearest. Use round_up or round_down for explicit direction.
|
|
85
|
+
#
|
|
86
|
+
# Price(50).round(9) # => Price(49) - nearest ending in 9
|
|
87
|
+
# Price(50).round_up(9) # => Price(59) - round up to ending 9
|
|
88
|
+
# Price(50).round_down(9) # => Price(49) - round down to ending 9
|
|
89
|
+
# Price(50).round(0.99) # => Price(49.99) - nearest ending in .99
|
|
90
|
+
#
|
|
91
|
+
def round(ending)
|
|
92
|
+
apply_discount Discount::Round::Nearest.new(ending)
|
|
64
93
|
end
|
|
65
94
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
95
|
+
def round_up(ending)
|
|
96
|
+
apply_discount Discount::Round::Up.new(ending)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def round_down(ending)
|
|
100
|
+
apply_discount Discount::Round::Down.new(ending)
|
|
71
101
|
end
|
|
72
102
|
|
|
73
103
|
def discounted?
|
|
74
|
-
!@
|
|
104
|
+
!@previous.nil?
|
|
75
105
|
end
|
|
76
106
|
|
|
77
|
-
|
|
78
|
-
|
|
107
|
+
# Returns the original price (walks all the way back in the discount chain)
|
|
108
|
+
def original
|
|
109
|
+
current = self
|
|
110
|
+
current = current.previous while current.previous
|
|
111
|
+
current
|
|
79
112
|
end
|
|
80
113
|
|
|
81
|
-
#
|
|
82
|
-
def
|
|
83
|
-
|
|
114
|
+
# Returns an Itemization enumerable for walking the discount chain.
|
|
115
|
+
def itemization
|
|
116
|
+
Itemization.new(self)
|
|
84
117
|
end
|
|
85
118
|
|
|
86
|
-
|
|
87
|
-
|
|
119
|
+
# Returns an Inspector for formatting the price breakdown as text.
|
|
120
|
+
def inspector
|
|
121
|
+
Inspector.new(itemization)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Returns the undiscounted price amount (walks up the discount chain)
|
|
125
|
+
def full_price
|
|
126
|
+
original.amount
|
|
88
127
|
end
|
|
89
128
|
|
|
129
|
+
def to_formatted_s(decimals: 2)
|
|
130
|
+
"%.#{decimals}f" % @amount.to_f
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def to_f = @amount.to_f
|
|
134
|
+
def to_d = @amount
|
|
135
|
+
def to_i = @amount.to_i
|
|
136
|
+
# Returns display-friendly string: whole numbers without decimals ("19"),
|
|
137
|
+
# cents with 2 decimals ("19.50"). Use to_formatted_s(decimals: 2) for
|
|
138
|
+
# consistent decimal places.
|
|
90
139
|
def to_s
|
|
91
|
-
@amount
|
|
140
|
+
if @amount % 1 == 0
|
|
141
|
+
@amount.to_i.to_s
|
|
142
|
+
else
|
|
143
|
+
"%.2f" % @amount.to_f
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def <=>(other)
|
|
148
|
+
case other
|
|
149
|
+
when Price then @amount <=> other.amount
|
|
150
|
+
when Numeric then @amount <=> to_decimal(other)
|
|
151
|
+
else nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def +(other) = build_price(@amount + to_amount(other))
|
|
156
|
+
def -(other) = build_price(@amount - to_amount(other))
|
|
157
|
+
def *(other) = build_price(@amount * to_amount(other))
|
|
158
|
+
def /(other) = build_price(@amount / to_amount(other))
|
|
159
|
+
def -@ = build_price(-@amount)
|
|
160
|
+
def abs = build_price(@amount.abs)
|
|
161
|
+
|
|
162
|
+
def zero? = @amount.zero?
|
|
163
|
+
alias free? zero?
|
|
164
|
+
|
|
165
|
+
def positive? = @amount.positive?
|
|
166
|
+
alias paid? positive?
|
|
167
|
+
|
|
168
|
+
def negative? = @amount.negative?
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def clamp(min, max) = build_price(@amount.clamp(to_amount(min), to_amount(max)))
|
|
172
|
+
|
|
173
|
+
# Enables `10 + Price(5)` by converting the numeric to a Price
|
|
174
|
+
def coerce(other)
|
|
175
|
+
case other
|
|
176
|
+
when Numeric then [build_price(other), self]
|
|
177
|
+
else raise TypeError, "#{other.class} can't be coerced into Price"
|
|
178
|
+
end
|
|
92
179
|
end
|
|
93
180
|
|
|
94
181
|
def inspect
|
|
95
182
|
if discounted?
|
|
96
|
-
"
|
|
183
|
+
"#<#{self.class.name} #{to_formatted_s} (was #{@previous.to_formatted_s}, #{discount.percent.to_f.round(1)}% off)>"
|
|
97
184
|
else
|
|
98
|
-
"
|
|
185
|
+
"#<#{self.class.name} #{to_formatted_s}>"
|
|
99
186
|
end
|
|
100
187
|
end
|
|
101
188
|
|
|
189
|
+
def pretty_inspect
|
|
190
|
+
receipt = inspector.to_s.lines.map { |line| "# #{line}" }.join
|
|
191
|
+
"#{inspect}\n#\n#{receipt}\n"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def pretty_print(pp)
|
|
195
|
+
pp.text(pretty_inspect)
|
|
196
|
+
end
|
|
197
|
+
|
|
102
198
|
private
|
|
103
199
|
|
|
200
|
+
def build_price(*, **)
|
|
201
|
+
Price.new(*, range: @range, **)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def clamp_to_range(value, range)
|
|
205
|
+
return value unless range
|
|
206
|
+
|
|
207
|
+
min = range.begin || -Float::INFINITY
|
|
208
|
+
max = range.end || Float::INFINITY
|
|
209
|
+
value.clamp(min, max)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def to_decimal(value)
|
|
213
|
+
case value
|
|
214
|
+
when BigDecimal then value
|
|
215
|
+
when Float then BigDecimal(value, 15)
|
|
216
|
+
else BigDecimal(value.to_s)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def to_amount(other)
|
|
221
|
+
case other
|
|
222
|
+
when Price then other.amount
|
|
223
|
+
when Numeric then to_decimal(other)
|
|
224
|
+
else raise ArgumentError, "Cannot convert #{other.class} to amount"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
104
228
|
def coerce_discount(source)
|
|
105
229
|
case source
|
|
106
230
|
when String then parse_discount_string(source)
|
|
107
|
-
when Numeric then Discount::Fixed.new(source)
|
|
231
|
+
when Numeric then Discount::Fixed.new to_decimal(source)
|
|
108
232
|
else source.to_discount
|
|
109
233
|
end
|
|
110
234
|
end
|
|
111
235
|
|
|
112
236
|
def parse_discount_string(str)
|
|
113
237
|
case str
|
|
114
|
-
when
|
|
115
|
-
|
|
116
|
-
when
|
|
117
|
-
|
|
238
|
+
when nil then Discount::NONE
|
|
239
|
+
when Discount::Percent::PATTERN then Discount::Percent.parse(str)
|
|
240
|
+
when Discount::Fixed::PATTERN then Discount::Fixed.parse(str)
|
|
241
|
+
else raise ArgumentError, "Invalid discount format: #{str.inspect}"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Enumerates prices in a discount chain from original to final.
|
|
247
|
+
#
|
|
248
|
+
# final = Price(100).apply_discount("20%").apply_discount("$10")
|
|
249
|
+
# itemization = Itemization.new(final)
|
|
250
|
+
#
|
|
251
|
+
# itemization.original # => Price(100)
|
|
252
|
+
# itemization.final # => Price(70)
|
|
253
|
+
# itemization.count # => 3
|
|
254
|
+
# itemization.each { |p| puts p }
|
|
255
|
+
#
|
|
256
|
+
class Itemization
|
|
257
|
+
include Enumerable
|
|
258
|
+
|
|
259
|
+
def initialize(price)
|
|
260
|
+
@final = price
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def each(&block)
|
|
264
|
+
return to_enum(:each) unless block_given?
|
|
265
|
+
|
|
266
|
+
to_a.each(&block)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def to_a
|
|
270
|
+
@prices ||= build_chain
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def original
|
|
274
|
+
to_a.first
|
|
275
|
+
end
|
|
276
|
+
alias first original
|
|
277
|
+
|
|
278
|
+
def final
|
|
279
|
+
@final
|
|
280
|
+
end
|
|
281
|
+
alias last final
|
|
282
|
+
|
|
283
|
+
def size
|
|
284
|
+
to_a.size
|
|
285
|
+
end
|
|
286
|
+
alias count size
|
|
287
|
+
alias length size
|
|
288
|
+
|
|
289
|
+
private
|
|
290
|
+
|
|
291
|
+
def build_chain
|
|
292
|
+
prices = []
|
|
293
|
+
current = @final
|
|
294
|
+
|
|
295
|
+
while current
|
|
296
|
+
prices.unshift(current)
|
|
297
|
+
current = current.previous
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
prices
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Formats a price itemization as a receipt-style text breakdown.
|
|
305
|
+
#
|
|
306
|
+
# final = Price(100).apply_discount("20%").apply_discount("$10")
|
|
307
|
+
# puts Inspector.new(final.itemization)
|
|
308
|
+
#
|
|
309
|
+
# # Output:
|
|
310
|
+
# # Original 100.00
|
|
311
|
+
# # 20% off -20.00
|
|
312
|
+
# # --------
|
|
313
|
+
# # Subtotal 80.00
|
|
314
|
+
# # $10 off -10.00
|
|
315
|
+
# # --------
|
|
316
|
+
# # FINAL 70.00
|
|
317
|
+
#
|
|
318
|
+
class Inspector
|
|
319
|
+
def initialize(itemization, label_width: 20, max_label_width: 30)
|
|
320
|
+
@itemization = itemization
|
|
321
|
+
@label_width = label_width
|
|
322
|
+
@max_label_width = max_label_width
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def to_s
|
|
326
|
+
items = @itemization.to_a
|
|
327
|
+
amount_width = calculate_amount_width(items)
|
|
328
|
+
separator_line = " " * @label_width + "-" * amount_width
|
|
329
|
+
|
|
330
|
+
output = []
|
|
331
|
+
output << format_line("Original", items.first.to_formatted_s, amount_width)
|
|
332
|
+
|
|
333
|
+
original_amount = items.first.amount
|
|
334
|
+
items.drop(1).each_with_index do |price, index|
|
|
335
|
+
cumulative_fixed = original_amount - price.amount
|
|
336
|
+
sign = cumulative_fixed.negative? ? "+" : "-"
|
|
337
|
+
discount_amount = "#{sign}%.2f" % cumulative_fixed.abs.to_f
|
|
338
|
+
label = price.discount.to_receipt_s
|
|
339
|
+
output << format_line(label, discount_amount, amount_width)
|
|
340
|
+
output << separator_line
|
|
341
|
+
|
|
342
|
+
is_last = index == items.length - 2
|
|
343
|
+
if is_last
|
|
344
|
+
output << format_line("FINAL", price.to_formatted_s, amount_width)
|
|
345
|
+
else
|
|
346
|
+
output << format_line("Subtotal", price.to_formatted_s, amount_width)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Handle case with no discounts
|
|
351
|
+
if items.length == 1
|
|
352
|
+
output << separator_line
|
|
353
|
+
output << format_line("FINAL", items.first.to_formatted_s, amount_width)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
output.join("\n")
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
private
|
|
360
|
+
|
|
361
|
+
def calculate_amount_width(items)
|
|
362
|
+
widths = items.map { |p| p.to_formatted_s.length }
|
|
363
|
+
items.drop(1).each do |p|
|
|
364
|
+
widths << "-#{p.discount.to_fixed_s}".length
|
|
365
|
+
end
|
|
366
|
+
widths.max + 2
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def format_line(label, amount, amount_width)
|
|
370
|
+
if label.length > @max_label_width
|
|
371
|
+
"#{label}\n#{' ' * @label_width}%#{amount_width}s" % [amount]
|
|
118
372
|
else
|
|
119
|
-
|
|
373
|
+
"%-#{@label_width}s%#{amount_width}s" % [label, amount]
|
|
120
374
|
end
|
|
121
375
|
end
|
|
122
376
|
end
|
|
377
|
+
|
|
123
378
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require 'bigdecimal'
|
|
2
|
+
|
|
3
|
+
module Superfeature
|
|
4
|
+
# Rounds values to a specified ending.
|
|
5
|
+
# For example, round to 9 gives $19, $29, $49, etc.
|
|
6
|
+
#
|
|
7
|
+
# Round.new(9).up(50) # => 59 (next value ending in 9)
|
|
8
|
+
# Round.new(9).down(50) # => 49 (previous value ending in 9)
|
|
9
|
+
# Round.new(9).nearest(50) # => 49 (closer to 49 than 59)
|
|
10
|
+
#
|
|
11
|
+
# Round.new(0.99).up(2.50) # => 2.99
|
|
12
|
+
# Round.new(0.99).down(2.50) # => 1.99
|
|
13
|
+
#
|
|
14
|
+
class Round
|
|
15
|
+
# Convenience methods to create round discounts
|
|
16
|
+
# Usage: Round::Up(9), Round::Down(9), Round::Nearest(9)
|
|
17
|
+
def self.Up(ending) = Discount::Round::Up.new(ending)
|
|
18
|
+
def self.Down(ending) = Discount::Round::Down.new(ending)
|
|
19
|
+
def self.Nearest(ending) = Discount::Round::Nearest.new(ending)
|
|
20
|
+
|
|
21
|
+
attr_reader :ending
|
|
22
|
+
|
|
23
|
+
def initialize(ending)
|
|
24
|
+
@ending = to_decimal(ending)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def up(value)
|
|
28
|
+
val = to_decimal(value)
|
|
29
|
+
return val if val.zero?
|
|
30
|
+
|
|
31
|
+
result = candidate(val)
|
|
32
|
+
result += interval if result < val
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def down(value)
|
|
37
|
+
val = to_decimal(value)
|
|
38
|
+
return val if val.zero?
|
|
39
|
+
|
|
40
|
+
result = candidate(val)
|
|
41
|
+
result -= interval if result > val
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def nearest(value)
|
|
46
|
+
val = to_decimal(value)
|
|
47
|
+
return val if val.zero?
|
|
48
|
+
|
|
49
|
+
up_val = up(val)
|
|
50
|
+
down_val = down(val)
|
|
51
|
+
|
|
52
|
+
distance_down = (val - down_val).abs
|
|
53
|
+
distance_up = (up_val - val).abs
|
|
54
|
+
distance_down <= distance_up ? down_val : up_val
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def to_decimal(value)
|
|
60
|
+
case value
|
|
61
|
+
when BigDecimal then value
|
|
62
|
+
when Float then BigDecimal(value, 15)
|
|
63
|
+
else BigDecimal(value.to_s)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Determine interval from ending
|
|
68
|
+
# 0.99 → interval of 1 (0.99, 1.99, 2.99...)
|
|
69
|
+
# 9 → interval of 10 (9, 19, 29...)
|
|
70
|
+
# 99 → interval of 100 (99, 199, 299...)
|
|
71
|
+
def interval
|
|
72
|
+
return BigDecimal("1") if @ending < 1
|
|
73
|
+
|
|
74
|
+
digits = @ending.to_i.to_s.length
|
|
75
|
+
BigDecimal("10") ** digits
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def candidate(val)
|
|
79
|
+
base = (val / interval).floor * interval
|
|
80
|
+
base + @ending
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/superfeature/version.rb
CHANGED
data/lib/superfeature.rb
CHANGED
|
@@ -4,15 +4,28 @@ require "superfeature/limit"
|
|
|
4
4
|
require "superfeature/feature"
|
|
5
5
|
require "superfeature/plan"
|
|
6
6
|
require "superfeature/plan/collection"
|
|
7
|
+
require "superfeature/round"
|
|
7
8
|
require "superfeature/discount"
|
|
8
9
|
require "superfeature/price"
|
|
9
10
|
|
|
10
11
|
module Superfeature
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
# Mix this in to get Price, Fixed, Percent helpers.
|
|
13
|
+
#
|
|
14
|
+
# class Plan
|
|
15
|
+
# include Superfeature::Pricing
|
|
16
|
+
#
|
|
17
|
+
# def monthly_price
|
|
18
|
+
# Price(29).apply_discount(Percent(20))
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
module Pricing
|
|
23
|
+
def Price(...) = Superfeature::Price.new(...)
|
|
24
|
+
def Fixed(...) = Discount::Fixed.new(...)
|
|
25
|
+
def Percent(...) = Discount::Percent.new(...)
|
|
26
|
+
Round = Superfeature::Round
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
include Pricing
|
|
30
|
+
extend Pricing
|
|
18
31
|
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.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brad Gessler
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-01-
|
|
10
|
+
date: 2026-01-21 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|
|
@@ -53,6 +53,9 @@ files:
|
|
|
53
53
|
- lib/generators/superfeature/plan/plan_generator.rb
|
|
54
54
|
- lib/generators/superfeature/plan/templates/plan.rb.tt
|
|
55
55
|
- lib/superfeature.rb
|
|
56
|
+
- lib/superfeature/core_ext.rb
|
|
57
|
+
- lib/superfeature/core_ext/numeric.rb
|
|
58
|
+
- lib/superfeature/core_ext/string.rb
|
|
56
59
|
- lib/superfeature/discount.rb
|
|
57
60
|
- lib/superfeature/engine.rb
|
|
58
61
|
- lib/superfeature/feature.rb
|
|
@@ -60,6 +63,7 @@ files:
|
|
|
60
63
|
- lib/superfeature/plan.rb
|
|
61
64
|
- lib/superfeature/plan/collection.rb
|
|
62
65
|
- lib/superfeature/price.rb
|
|
66
|
+
- lib/superfeature/round.rb
|
|
63
67
|
- lib/superfeature/version.rb
|
|
64
68
|
- lib/tasks/superfeature_tasks.rake
|
|
65
69
|
homepage: https://github.com/rubymonolith/superfeature
|