All notable changes to this project will be documented in this file.
-
Pricenow usesBigDecimalinternally - All price calculations now useBigDecimalto avoid floating-point precision errors. This fixes issues like49.99 - 20.00returning29.990000000000002.price = Price(49.99) price.amount # => BigDecimal("49.99") price.to_f # => 49.99 (Float) price.to_i # => 49 (Integer) price.to_d # => BigDecimal("49.99")
-
Renamed
Price#to(amount)toPrice#discount_to(amount)- Clearer naming for setting a target price:# Before price = Price(300).to(200) # After price = Price(300).discount_to(200)
-
Renamed
Price#discount(source)toPrice#apply_discount(source)- Thediscountmethod is now purely an accessor that returns the applied discount object. Useapply_discountto apply discounts.# Before price = Price(100).discount(Percent(20)) # After price = Price(100).apply_discount(Percent(20))
-
Removed
Price#fixed_discountandPrice#percent_discountmethods - Useprice.discount.fixedandprice.discount.percentinstead:# Before price.fixed_discount # => 20.0 price.percent_discount # => 0.20 # After price.discount.fixed # => 20.0 price.discount.percent # => 20.0 (now returns percentage, not decimal)
-
Removed
Price#discount_source- Useprice.discount.sourceinstead:# Before price.discount_source # => the original discount object # After price.discount.source # => the original discount object
-
Removed
amount_precisionandpercent_precisionfromPrice- Precision is now a display concern. Use thedecimals:kwarg on formatting methods:# Before Price(49.99, amount_precision: 3).to_formatted_s # After Price(49.99).to_formatted_s(decimals: 3)
-
Added
rangeparameter toPrice(default0..) - Prices are clamped to this range by default. Userange: nilto allow negative prices:Price(-10).amount # => 0.0 (clamped to range 0..) Price(-10, range: nil).amount # => -10.0 (no clamping) Price(50, range: 10..100).amount # => 50.0 (within range)
-
Price#to_d- Returns the amount as aBigDecimal:Price(49.99).to_d # => BigDecimal("49.99")
-
Price#to_i- Returns the amount as an integer (truncated):Price(49.99).to_i # => 49
-
Price#to_snow formats display-friendly - Whole numbers omit decimals, cents show 2 decimal places:Price(19).to_s # => "19" Price(19.99).to_s # => "19.99" Price(19.50).to_s # => "19.50" # For consistent decimals, use to_formatted_s Price(19).to_formatted_s(decimals: 2) # => "19.00"
-
free?andpaid?aliases - More readable alternatives:Price(0).free? # => true (alias for zero?) Price(100).paid? # => true (alias for positive?)
-
Discount::Nonenull object -price.discountnow returns aNoneobject instead ofnilwhen no discount is applied, enabling safe chaining without&.:price = Price(100) price.discount.none? # => true price.discount.to_percent_s # => "0%" price.discount.to_fixed_s # => "0.00" price.discount.to_formatted_s # => ""
-
Comparison operators on
Price- Prices can now be compared with other prices or numerics using<,>,<=,>=,==:Price(100) > Price(50) # => true Price(100) == 100 # => true Price(100) < 200 # => true discounted = Price(100).apply_discount(Percent(20)) discounted < Price(100) # => true discounted == 80 # => true
-
Math operators on
Price- Prices support+,-,*,/with other prices or numerics. Returns a new Price without discount info:Price(100) + 20 # => Price(120) Price(100) + Price(50) # => Price(150) Price(100) - 20 # => Price(80) Price(100) * 2 # => Price(200) Price(100) / 4 # => Price(25)
-
Unary minus - Negate a price for credits/refunds:
-Price(100, range: nil) # => Price(-100)
-
abs- Get absolute value:Price(-100, range: nil).abs # => Price(100)
-
zero?,positive?,negative?- Query price state:Price(0).zero? # => true Price(100).positive? # => true Price(-50, range: nil).negative? # => true
-
round- Round to specified precision:Price(19.999).round # => Price(20.00) Price(19.456).round(2) # => Price(19.46)
-
clamp- Constrain price within bounds:Price(150, range: nil).clamp(0, 100) # => Price(100) Price(-50, range: nil).clamp(0, 100) # => Price(0)
-
Coercion - Enables
Numeric + Price(not justPrice + Numeric):10 + Price(5) # => Price(15) 100 - Price(30) # => Price(70)
-
Rounding to endings - Round prices to specific endings (e.g., $9.99, $19, $29).
round()defaults to nearest, withround_upandround_downfor explicit direction:# round(9) - defaults to nearest rounding Price(50).round(9) # => Price(49) (nearest) Price(50).round_up(9) # => Price(59) (round up) Price(50).round_down(9) # => Price(49) (round down) # Prices ending in .99 ($0.99, $1.99, $2.99...) Price(2.50).round(0.99) # => Price(2.99) Price(2.50).round_up(0.99) # => Price(2.99) Price(2.50).round_down(0.99) # => Price(1.99) # Prices ending in 99 ($99, $199, $299...) Price(150).round_up(99) # => Price(199)
-
Discount::Appliedwrapper class - When a discount is applied,price.discountnow returns anAppliedobject with computed values and formatting helpers:price = Price(100).apply_discount(Percent(20)) price.discount # => Discount::Applied price.discount.percent # => 20.0 (computed percent saved) price.discount.fixed # => 20.0 (computed dollars saved) price.discount.to_percent_s # => "20%" price.discount.to_fixed_s # => "20.00" price.discount.to_formatted_s # => "20%" (natural format from source) price.discount.source # => the original Discount::Percent object
-
to_formatted_sonDiscount::FixedandDiscount::Percent- Format discount values for display:Discount::Fixed.new(20).to_formatted_s # => "20" Discount::Percent.new(50).to_formatted_s # => "50%"
-
Itemizationclass for walking discount chains - Enumerate all prices in a discount chain from original to final:final = Price(100).apply_discount("20%").apply_discount("$10") # Access via Price#itemization final.itemization.original # => Price(100) final.itemization.final # => Price(70) final.itemization.count # => 3 # Enumerable - iterate from original to final final.itemization.each { |p| puts "#{p} (#{p.discount.to_formatted_s})" } # 100 () # 80 (20%) # 70 ($10) # Use Enumerable methods final.itemization.map(&:to_s) # => ["100", "80", "70"] final.itemization.select(&:discounted?) # => [Price(80), Price(70)]
-
Price#previousfor immediate parent price - Access the price before the most recent discount:final = Price(100).apply_discount("20%").apply_discount("$10") final.previous # => Price(80) - immediate parent final.previous.previous # => Price(100) - one more step back final.original # => Price(100) - walks all the way back
-
Price#originalnow returns the true original price - Walks all the way back to the first price in the chain:final = Price(100).apply_discount("20%").apply_discount("$10") final.original # => Price(100) - the starting price final.original == final.itemization.first # => true
-
Inspectorclass for receipt-style formatting - Format a price breakdown as a readable receipt. This was made possible byItemization:final = Price(100).apply_discount("20%").apply_discount("$10") puts final.inspector # Output: # Original 100.00 # 20% off -20.00 # -------- # Subtotal 80.00 # 10 off -10.00 # -------- # FINAL 70.00
Use
ppin the console for quick debugging:pp final # Original 100.00 # 20% off -20.00 # -------- # Subtotal 80.00 # 10 off -10.00 # -------- # FINAL 70.00
In a Rails ERB template, use
Itemizationto display a price breakdown:<table class="price-breakdown"> <% @price.itemization.each do |price| %> <tr> <% if price.discounted? %> <td><%= price.discount.to_formatted_s %> off</td> <td class="amount">-<%= price.discount.to_fixed_s %></td> <% else %> <td>Original</td> <td class="amount"><%= price.to_formatted_s %></td> <% end %> </tr> <% end %> <tr class="total"> <td>Total</td> <td class="amount"><%= @price.to_formatted_s %></td> </tr> </table>
- Replace all calls to
price.discount(source)withprice.apply_discount(source) - Replace all calls to
price.to(amount)withprice.discount_to(amount) - Replace
price.fixed_discountwithprice.discount.fixed - Replace
(price.percent_discount * 100).to_iwithprice.discount.percent.to_iorprice.discount.to_percent_s - Replace
price.discount_sourcewithprice.discount.source - Replace
amount_precision:andpercent_precision:withdecimals:kwarg on formatting methods - If you relied on negative prices, add
range: nilto your Price constructor - If you were comparing
price.amountas a Float, note it's now a BigDecimal (useto_fif needed) - Replace
price.originalwithprice.previousif you need the immediate parent price (one step back).price.originalnow walks all the way back to the first price in the chain.