1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00

Multi-currency support: Money + Currency class improvements (#553)

* Money improvements

* Replace all old money usage
This commit is contained in:
Zach Gollwitzer 2024-03-18 11:21:00 -04:00 committed by GitHub
parent e5750d1a13
commit fe2fa0eac1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 2982 additions and 196 deletions

61
lib/money.rb Normal file
View file

@ -0,0 +1,61 @@
class Money
include Comparable
include Arithmetic
attr_reader :amount, :currency
class << self
def default_currency
@default ||= Money::Currency.new(:usd)
end
def default_currency=(object)
@default = Money::Currency.new(object)
end
end
def initialize(obj, currency = Money.default_currency)
unless obj.is_a?(Money) || obj.is_a?(Numeric) || obj.is_a?(BigDecimal)
raise ArgumentError, "obj must be an instance of Money, Numeric, or BigDecimal"
end
@amount = obj.is_a?(Money) ? obj.amount : BigDecimal(obj.to_s)
@currency = obj.is_a?(Money) ? obj.currency : Money::Currency.new(currency)
end
def cents_str(precision = @currency.default_precision)
format_str = "%.#{precision}f"
amount_str = format_str % @amount
parts = amount_str.split(@currency.separator)
return "" if parts.length < 2
parts.last.ljust(precision, "0")
end
# Basic formatting only. Use the Rails number_to_currency helper for more advanced formatting.
alias to_s format
def format
whole_part, fractional_part = sprintf("%.#{@currency.default_precision}f", @amount).split(".")
whole_with_delimiters = whole_part.chars.to_a.reverse.each_slice(3).map(&:join).join(@currency.delimiter).reverse
formatted_amount = "#{whole_with_delimiters}#{@currency.separator}#{fractional_part}"
@currency.default_format.gsub("%n", formatted_amount).gsub("%u", @currency.symbol)
end
def <=>(other)
raise TypeError, "Money can only be compared with other Money objects except for 0" unless other.is_a?(Money) || other.eql?(0)
return @amount <=> other if other.is_a?(Numeric)
amount_comparison = @amount <=> other.amount
return amount_comparison unless amount_comparison == 0
@currency <=> other.currency
end
def default_format_options
{
unit: @currency.symbol,
precision: @currency.default_precision,
delimiter: @currency.delimiter,
separator: @currency.separator
}
end
end

62
lib/money/arithmetic.rb Normal file
View file

@ -0,0 +1,62 @@
module Money::Arithmetic
CoercedNumeric = Struct.new(:value)
def +(other)
if other.is_a?(Money)
self.class.new(amount + other.amount, currency)
else
value = other.is_a?(CoercedNumeric) ? other.value : other
self.class.new(amount + value, currency)
end
end
def -(other)
if other.is_a?(Money)
self.class.new(amount - other.amount, currency)
else
value = other.is_a?(CoercedNumeric) ? other.value : other
self.class.new(amount - value, currency)
end
end
def -@
self.class.new(-amount, currency)
end
def *(other)
raise TypeError, "Can't multiply Money by Money, use Numeric instead" if other.is_a?(self.class)
value = other.is_a?(CoercedNumeric) ? other.value : other
self.class.new(amount * value, currency)
end
def /(other)
if other.is_a?(self.class)
amount / other.amount
else
raise TypeError, "can't divide Numeric by Money" if other.is_a?(CoercedNumeric)
self.class.new(amount / other, currency)
end
end
def abs
self.class.new(amount.abs, currency)
end
def zero?
amount.zero?
end
def negative?
amount.negative?
end
def positive?
amount.positive?
end
# Override Ruby's coerce method so the order of operands doesn't matter
# Wrap in Coerced so we can distinguish between Money and other types
def coerce(other)
[ self, CoercedNumeric.new(other) ]
end
end

61
lib/money/currency.rb Normal file
View file

@ -0,0 +1,61 @@
class Money::Currency
include Comparable
class UnknownCurrencyError < ArgumentError; end
CURRENCIES_FILE_PATH = Rails.root.join("config", "currencies.yml")
# Cached instances by iso code
@@instances = {}
class << self
def new(object)
iso_code = case object
when String, Symbol
object.to_s.downcase
when Money::Currency
object.iso_code.downcase
else
raise ArgumentError, "Invalid argument type"
end
@@instances[iso_code] ||= super(iso_code)
end
def all
@all ||= YAML.load_file(CURRENCIES_FILE_PATH)
end
def popular
all.values.sort_by { |currency| currency["priority"] }.first(12).map { |currency_data| new(currency_data["iso_code"]) }
end
end
attr_reader :name, :priority, :iso_code, :iso_numeric, :html_code,
:symbol, :minor_unit, :minor_unit_conversion, :smallest_denomination,
:separator, :delimiter, :default_format, :default_precision
def initialize(iso_code)
currency_data = self.class.all[iso_code]
raise UnknownCurrencyError if currency_data.nil?
@name = currency_data["name"]
@priority = currency_data["priority"]
@iso_code = currency_data["iso_code"]
@iso_numeric = currency_data["iso_numeric"]
@html_code = currency_data["html_code"]
@symbol = currency_data["symbol"]
@minor_unit = currency_data["minor_unit"]
@minor_unit_conversion = currency_data["minor_unit_conversion"]
@smallest_denomination = currency_data["smallest_denomination"]
@separator = currency_data["separator"]
@delimiter = currency_data["delimiter"]
@default_format = currency_data["default_format"]
@default_precision = currency_data["default_precision"]
end
def <=>(other)
return nil unless other.is_a?(Money::Currency)
@iso_code <=> other.iso_code
end
end