From 03b5de283a47643a21f7e6f27ccdf926b3efaabf Mon Sep 17 00:00:00 2001 From: Michael Elfassy Date: Tue, 3 Feb 2026 14:47:27 -0500 Subject: [PATCH] Add per_unit and reverse_per_unit methods to Money class These methods calculate unit prices based on a total quantity of units, with proper rounding to avoid losing subunits. - per_unit: Uses higher-valued splits first (units with the "extra penny") - reverse_per_unit: Uses lower-valued splits first Both methods support a take parameter to sum multiple unit prices while maintaining correct rounding. Taking all units returns the original amount. Use case: When splitting a total price across items and needing to know the per-item cost, these methods ensure all money is properly accounted for. Co-Authored-By: Claude Opus 4.5 --- lib/money/money.rb | 60 +++++++++++++++++++++++++++++++++++ spec/splitter_spec.rb | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/lib/money/money.rb b/lib/money/money.rb index bae1cafd..d683c1da 100644 --- a/lib/money/money.rb +++ b/lib/money/money.rb @@ -342,6 +342,66 @@ def calculate_splits(num) Splitter.new(self, num).split.dup end + # Calculate the unit price based on a total quantity of units. + # A number of units to take can be optionally provided to get the sum of + # their unit prices with correct rounding. + # + # The higher-valued splits are used first (i.e., units with the "extra penny"). + # + # @param quantity [Integer] total number of units + # @param take [Integer] number of unit prices to sum (default: 1) + # + # @return [Money] + # + # @example Get the unit price for 1 unit out of 3 + # Money.new(1.00, 'USD').per_unit(3) + # # => Money.new(0.34, 'USD') # the higher-valued split + # + # @example Get total for 3 units out of 3 (returns original amount) + # Money.new(1.00, 'USD').per_unit(3, take: 3) + # # => Money.new(1.00, 'USD') + def per_unit(quantity, take: 1) + raise ArgumentError, "take should be positive" if take < 0 + raise ArgumentError, "quantity should be positive" if quantity <= 0 + raise ArgumentError, "take cannot be greater than quantity" if take > quantity + + calculate_splits(quantity).sum(Money.new(0, currency)) do |value, count| + count = [take, count].min + take -= count + value * count + end + end + + # Calculate the unit price based on a total quantity of units. + # Uses the lesser-valued splits first (i.e., units without the "extra penny"). + # + # A number of units to take can be optionally provided to get the sum of + # their unit prices with correct rounding. + # + # @param quantity [Integer] total number of units + # @param take [Integer] number of unit prices to sum (default: 1) + # + # @return [Money] + # + # @example Get the unit price for 1 unit out of 3 (lower value) + # Money.new(1.00, 'USD').reverse_per_unit(3) + # # => Money.new(0.33, 'USD') # the lower-valued split + # + # @example Get total for 3 units out of 3 (returns original amount) + # Money.new(1.00, 'USD').reverse_per_unit(3, take: 3) + # # => Money.new(1.00, 'USD') + def reverse_per_unit(quantity, take: 1) + raise ArgumentError, "quantity should be positive" if quantity < 0 + raise ArgumentError, "take should be positive" if take <= 0 + raise ArgumentError, "take cannot be greater than quantity" if take > quantity + + calculate_splits(quantity).reverse_each.sum(Money.new(0, currency)) do |value, count| + count = [take, count].min + take -= count + value * count + end + end + # Clamps the value to be within the specified minimum and maximum. Returns # self if the value is within bounds, otherwise a new Money object with the # closest min or max value. diff --git a/spec/splitter_spec.rb b/spec/splitter_spec.rb index 42396dab..33ec2915 100644 --- a/spec/splitter_spec.rb +++ b/spec/splitter_spec.rb @@ -107,4 +107,78 @@ }) end end + + describe "per_unit" do + specify "#per_unit returns the higher-valued split by default" do + # $1 split 3 ways: 34¢, 33¢, 33¢ + expect(Money.new(1.00, 'USD').per_unit(3)).to eq(Money.new(0.34, 'USD')) + end + + specify "#per_unit with take returns sum of unit prices" do + # Take 2 units: 34¢ + 33¢ = 67¢ + expect(Money.new(1.00, 'USD').per_unit(3, take: 2)).to eq(Money.new(0.67, 'USD')) + end + + specify "#per_unit with take: quantity returns original amount" do + expect(Money.new(1.00, 'USD').per_unit(3, take: 3)).to eq(Money.new(1.00, 'USD')) + end + + specify "#per_unit works with non-decimal currencies" do + # 100 JPY split 3 ways: 34, 33, 33 + expect(Money.new(100, 'JPY').per_unit(3)).to eq(Money.new(34, 'JPY')) + expect(Money.new(100, 'JPY').per_unit(3, take: 2)).to eq(Money.new(67, 'JPY')) + end + + specify "#per_unit raises for invalid take" do + expect { Money.new(1.00, 'USD').per_unit(3, take: -1) }.to raise_error(ArgumentError, "take should be positive") + end + + specify "#per_unit raises for invalid quantity" do + expect { Money.new(1.00, 'USD').per_unit(0) }.to raise_error(ArgumentError, "quantity should be positive") + expect { Money.new(1.00, 'USD').per_unit(-1) }.to raise_error(ArgumentError, "quantity should be positive") + end + + specify "#per_unit raises when take exceeds quantity" do + expect { Money.new(1.00, 'USD').per_unit(3, take: 4) }.to raise_error(ArgumentError, "take cannot be greater than quantity") + end + + specify "#per_unit returns zero when take is 0" do + expect(Money.new(1.00, 'USD').per_unit(3, take: 0)).to eq(Money.new(0, 'USD')) + end + end + + describe "reverse_per_unit" do + specify "#reverse_per_unit returns the lower-valued split by default" do + # $1 split 3 ways: 34¢, 33¢, 33¢ (reversed: 33¢, 33¢, 34¢) + expect(Money.new(1.00, 'USD').reverse_per_unit(3)).to eq(Money.new(0.33, 'USD')) + end + + specify "#reverse_per_unit with take returns sum of lower-valued unit prices" do + # Take 2 units (from reversed): 33¢ + 33¢ = 66¢ + expect(Money.new(1.00, 'USD').reverse_per_unit(3, take: 2)).to eq(Money.new(0.66, 'USD')) + end + + specify "#reverse_per_unit with take: quantity returns original amount" do + expect(Money.new(1.00, 'USD').reverse_per_unit(3, take: 3)).to eq(Money.new(1.00, 'USD')) + end + + specify "#reverse_per_unit works with non-decimal currencies" do + # 100 JPY split 3 ways: 34, 33, 33 (reversed: 33, 33, 34) + expect(Money.new(100, 'JPY').reverse_per_unit(3)).to eq(Money.new(33, 'JPY')) + expect(Money.new(100, 'JPY').reverse_per_unit(3, take: 2)).to eq(Money.new(66, 'JPY')) + end + + specify "#reverse_per_unit raises for invalid take" do + expect { Money.new(1.00, 'USD').reverse_per_unit(3, take: 0) }.to raise_error(ArgumentError, "take should be positive") + expect { Money.new(1.00, 'USD').reverse_per_unit(3, take: -1) }.to raise_error(ArgumentError, "take should be positive") + end + + specify "#reverse_per_unit raises for invalid quantity" do + expect { Money.new(1.00, 'USD').reverse_per_unit(-1) }.to raise_error(ArgumentError, "quantity should be positive") + end + + specify "#reverse_per_unit raises when take exceeds quantity" do + expect { Money.new(1.00, 'USD').reverse_per_unit(3, take: 4) }.to raise_error(ArgumentError, "take cannot be greater than quantity") + end + end end