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.
@@ -1,24 +1,42 @@
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
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
- DEFAULT_AMOUNT_PRECISION = 2
12
- DEFAULT_PERCENT_PRECISION = 4
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
- attr_reader :amount, :original, :discount_source, :amount_precision, :percent_precision
29
+ def price = self
15
30
 
16
- def initialize(amount, original: nil, discount_source: nil, amount_precision: DEFAULT_AMOUNT_PRECISION, percent_precision: DEFAULT_PERCENT_PRECISION)
17
- @amount = amount.to_f
18
- @original = original
19
- @discount_source = discount_source
20
- @amount_precision = amount_precision
21
- @percent_precision = percent_precision
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 discount(source)
31
- return self if source.nil?
32
-
33
- discount_obj = coerce_discount(source)
34
- new_amount = [discount_obj.apply(@amount), 0].max.round(@amount_precision)
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
- Price.new(new_amount,
37
- original: self,
38
- discount_source: source,
39
- amount_precision: @amount_precision,
40
- percent_precision: @percent_precision
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
- discount(Discount::Fixed.new(amount.to_f))
68
+ apply_discount Discount::Fixed.new to_decimal(amount)
47
69
  end
48
70
 
49
- # Set the price to a specific amount (calculates discount from current amount)
50
- # Price(300).to(200) is equivalent to Price(300).discount_fixed(100)
51
- def to(new_amount)
52
- discount_fixed([@amount - new_amount.to_f, 0].max)
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 (decimal, e.g., 0.25 for 25%)
78
+ # Apply a percentage discount (e.g., 50 for 50% off)
56
79
  def discount_percent(percent)
57
- discount(Discount::Percent.new(percent.to_f * 100))
80
+ apply_discount Discount::Percent.new(percent)
58
81
  end
59
82
 
60
- # Dollars saved from original price
61
- def fixed_discount
62
- return 0.0 unless @original
63
- (@original.amount - @amount).round(@amount_precision)
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
- # Percent saved as decimal (e.g., 0.25 for 25%)
67
- def percent_discount
68
- return 0.0 unless @original
69
- return 0.0 if @original.amount.zero?
70
- ((@original.amount - @amount) / @original.amount).round(@percent_precision)
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
- !@original.nil?
104
+ !@previous.nil?
75
105
  end
76
106
 
77
- def full_price
78
- @original ? @original.amount : @amount
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
- # Format amount as string with configured precision
82
- def to_formatted_s
83
- "%.#{@amount_precision}f" % @amount
114
+ # Returns an Itemization enumerable for walking the discount chain.
115
+ def itemization
116
+ Itemization.new(self)
84
117
  end
85
118
 
86
- def to_f
87
- @amount
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.to_s
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
- "#<Price #{to_formatted_s} (was #{@original.to_formatted_s}, #{(percent_discount * 100).round(1)}% off)>"
183
+ "#<#{self.class.name} #{to_formatted_s} (was #{@previous.to_formatted_s}, #{discount.percent.to_f.round(1)}% off)>"
97
184
  else
98
- "#<Price #{to_formatted_s}>"
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 /\A(\d+(?:\.\d+)?)\s*%\z/
115
- Discount::Percent.new($1.to_f)
116
- when /\A\$?\s*(\d+(?:\.\d+)?)\z/
117
- Discount::Fixed.new($1.to_f)
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
- raise ArgumentError, "Invalid discount format: #{str.inspect}"
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
@@ -1,3 +1,3 @@
1
1
  module Superfeature
2
- VERSION = "0.1.7"
2
+ VERSION = "0.1.8"
3
3
  end
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
- # Convenience methods for creating Discount objects.
12
- # Use Superfeature::Fixed(20) or after `include Superfeature`, just Fixed(20)
13
- def Fixed(...) = Discount::Fixed.new(...)
14
- def Percent(...) = Discount::Percent.new(...)
15
- def Bundle(...) = Discount::Bundle.new(...)
16
- module_function :Fixed, :Percent, :Bundle
17
- public :Fixed, :Percent, :Bundle
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.7
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-09 00:00:00.000000000 Z
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