class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
@amount = BigDecimal(amount.to_s)
@currency = currency.upcase
freeze # Make immutable
end
def +(other)
ensure_same_currency!(other)
Money.new(amount + other.amount, currency)
end
def -(other)
ensure_same_currency!(other)
Money.new(amount - other.amount, currency)
end
def *(multiplier)
Money.new(amount * multiplier, currency)
end
def /(divisor)
Money.new(amount / divisor, currency)
end
def <=>(other)
ensure_same_currency!(other)
amount <=> other.amount
end
def ==(other)
other.is_a?(Money) &&
amount == other.amount &&
currency == other.currency
end
alias eql? ==
def hash
[amount, currency].hash
end
def to_s
"#{currency_symbol}#{formatted_amount}"
end
def to_f
amount.to_f
end
def zero?
amount.zero?
end
def positive?
amount.positive?
end
def negative?
amount.negative?
end
private
def ensure_same_currency!(other)
return if currency == other.currency
raise ArgumentError,
"Cannot operate on different currencies: #{currency} and #{other.currency}"
end
def currency_symbol
{ 'USD' => '$', 'EUR' => '€', 'GBP' => '£' }[currency] || currency
end
def formatted_amount
format('%.2f', amount)
end
end
# Usage
price = Money.new(19.99, 'USD')
tax = Money.new(2.50, 'USD')
total = price + tax
# => $22.49
discount = total * 0.10
final_price = total - discount
# Comparison
price > tax # => true
[price, tax, discount].sort # => sorted by amount
# Equality
Money.new(10, 'USD') == Money.new(10, 'USD') # => true
Money.new(10, 'USD') == Money.new(10, 'EUR') # => false
# Using in models
class Order < ApplicationRecord
def total_price
Money.new(total_cents / 100.0, 'USD')
end
def total_price=(money)
self.total_cents = (money.amount * 100).to_i
end
end
order = Order.new
order.total_price = Money.new(99.99)
order.total_price # => $99.99
# Address value object
class Address
attr_reader :street, :city, :state, :zip_code, :country
def initialize(street:, city:, state:, zip_code:, country: 'USA')
@street = street
@city = city
@state = state
@zip_code = zip_code
@country = country
validate!
freeze
end
def full_address
"#{street}, #{city}, #{state} #{zip_code}, #{country}"
end
def ==(other)
other.is_a?(Address) &&
street == other.street &&
city == other.city &&
state == other.state &&
zip_code == other.zip_code &&
country == other.country
end
private
def validate!
raise ArgumentError, "Invalid address" if [street, city, state, zip_code].any?(&:blank?)
end
end
# DateRange value object
class DateRange
include Enumerable
attr_reader :start_date, :end_date
def initialize(start_date, end_date)
@start_date = start_date.to_date
@end_date = end_date.to_date
raise ArgumentError, "End date must be after start date" if @end_date < @start_date
freeze
end
def include?(date)
date = date.to_date
start_date <= date && date <= end_date
end
def days
(end_date - start_date).to_i + 1
end
def each(&block)
(start_date..end_date).each(&block)
end
def overlaps?(other)
start_date <= other.end_date && end_date >= other.start_date
end
def ==(other)
other.is_a?(DateRange) &&
start_date == other.start_date &&
end_date == other.end_date
end
def to_s
"#{start_date} to #{end_date}"
end
end
# Coordinate value object
class Coordinate
include Comparable
attr_reader :latitude, :longitude
EARTH_RADIUS_KM = 6371
def initialize(latitude, longitude)
@latitude = latitude.to_f
@longitude = longitude.to_f
validate!
freeze
end
def distance_to(other)
# Haversine formula
lat1 = to_radians(latitude)
lat2 = to_radians(other.latitude)
delta_lat = to_radians(other.latitude - latitude)
delta_lon = to_radians(other.longitude - longitude)
a = Math.sin(delta_lat / 2)**2 +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(delta_lon / 2)**2
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
EARTH_RADIUS_KM * c
end
def ==(other)
other.is_a?(Coordinate) &&
latitude == other.latitude &&
longitude == other.longitude
end
def to_s
"#{latitude}, #{longitude}"
end
private
def validate!
unless latitude.between?(-90, 90) && longitude.between?(-180, 180)
raise ArgumentError, "Invalid coordinates"
end
end
def to_radians(degrees)
degrees * Math::PI / 180
end
end
# Usage
address = Address.new(
street: '123 Main St',
city: 'San Francisco',
state: 'CA',
zip_code: '94102'
)
range = DateRange.new('2024-01-01', '2024-01-31')
range.days # => 31
range.include?(Date.today)
range.each { |date| puts date }
sf = Coordinate.new(37.7749, -122.4194)
la = Coordinate.new(34.0522, -118.2437)
distance = sf.distance_to(la) # => ~559 km