Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial extraction.

  • Loading branch information...
commit b416d30e5a1abd6895773621266b31956a8ec212 0 parents
@tobi authored
25 README
@@ -0,0 +1,25 @@
+= Money Column
+
+Rails plugin that makes handling of money values in the database convenient. On assignment the money column will
+parse the input and apply heuristics to normalize oddball user input.
+
+Extracted from Shopify and has seen years of production use.
+
+= Example use
+
+ class Product < ActiveRecord::Base
+ money_column :price
+ end
+
+ sneakers = Product.new :price => '$199.95'
+ sneakers.price #=> #<Money value:199.95>
+
+= Database
+
+All money values need to be declared as decimal columns. Just go for it. It will safe you from a world of trouble.
+
+ create_table "products" do |t|
+ t.string "title"
+ t.decimal "price", :precision => 10, :scale => 2, :default => 0.0
+ end
+
14 Rakefile
@@ -0,0 +1,14 @@
+#!/usr/bin/env ruby
+require 'rubygems'
+require 'rake'
+require 'spec/rake/spectask'
+
+
+task :default => [:spec]
+
+desc "Run all spec examples"
+Spec::Rake::SpecTask.new do |t|
+ t.libs << "spec"
+ t.spec_files = FileList['spec/**/*_spec.rb']
+ t.spec_opts = ['--options', %\"#{File.dirname(__FILE__)}/spec/spec.opts"\]
+end
4 init.rb
@@ -0,0 +1,4 @@
+require 'money'
+require 'money_column'
+
+ActiveRecord::Base.send :include, MoneyColumn
3  lib/money.rb
@@ -0,0 +1,3 @@
+require File.dirname(__FILE__) + '/money/money'
+require File.dirname(__FILE__) + '/money/money_parser'
+require File.dirname(__FILE__) + '/money/core_extensions'
18 lib/money/core_extensions.rb
@@ -0,0 +1,18 @@
+# Allows Writing of 100.to_money for +Numeric+ types
+# 100.to_money => #<Money @cents=10000>
+# 100.37.to_money => #<Money @cents=10037>
+class Numeric
+ def to_money
+ Money.new(self)
+ end
+end
+
+# Allows Writing of '100'.to_money for +String+ types
+# Excess characters will be discarded
+# '100'.to_money => #<Money @cents=10000>
+# '100.37'.to_money => #<Money @cents=10037>
+class String
+ def to_money
+ empty? ? Money.empty : Money.parse(self)
+ end
+end
112 lib/money/money.rb
@@ -0,0 +1,112 @@
+require 'bigdecimal'
+require 'bigdecimal/util'
+
+class Money
+ include Comparable
+
+ attr_reader :value, :cents
+
+ def initialize(value = 0)
+ raise ArgumentError if value.respond_to?(:nan?) && value.nan?
+
+ @value = value_to_decimal(value).round(2)
+ @cents = (@value * 100).to_i
+ end
+
+ def <=>(other)
+ cents <=> other.cents
+ end
+
+ def +(other)
+ Money.new(value + other.to_money.value)
+ end
+
+ def -(other)
+ Money.new(value - other.to_money.value)
+ end
+
+ def *(numeric)
+ Money.new(value * numeric)
+ end
+
+ def /(numeric)
+ Money.new(value / numeric)
+ end
+
+ def inspect
+ "#<#{self.class} value:#{self.to_s}>"
+ end
+
+ def ==(other)
+ eql?(other)
+ end
+
+ def eql?(other)
+ self.class == other.class && value == other.value
+ end
+
+ def hash
+ value.hash
+ end
+
+ def self.parse(input)
+ MoneyParser.parse(input)
+ end
+
+ def self.empty
+ Money.new
+ end
+
+ def self.from_cents(cents)
+ Money.new(cents.round.to_f / 100)
+ end
+
+ def to_money
+ self
+ end
+
+ def zero?
+ value.zero?
+ end
+
+ # dangerous, this *will* shave off all your cents
+ def to_i
+ value.to_i
+ end
+
+ def to_f
+ value.to_f
+ end
+
+ def to_s
+ sprintf("%.2f", value.to_f)
+ end
+
+ def to_liquid
+ cents
+ end
+
+ def to_json(options = {})
+ cents
+ end
+
+ def abs
+ Money.new(value.abs)
+ end
+
+ private
+ # poached from Rails
+ def value_to_decimal(value)
+ # Using .class is faster than .is_a? and
+ # subclasses of BigDecimal will be handled
+ # in the else clause
+ if value.class == BigDecimal
+ value
+ elsif value.respond_to?(:to_d)
+ value.to_d
+ else
+ value.to_s.to_d
+ end
+ end
+end
+
33 lib/money/money_parser.rb
@@ -0,0 +1,33 @@
+class MoneyParser
+ ZERO_MONEY = "0.00"
+
+ # parse a amount from a string
+ def self.parse(input)
+ new.parse(input)
+ end
+
+ def parse(input)
+ Money.new(extract_money(input))
+ end
+
+ private
+ def extract_money(input)
+ return ZERO_MONEY if input.to_s.empty?
+
+ amount = input.scan(/\-?[\d\.\,]+/).first
+
+ return ZERO_MONEY if amount.nil?
+
+ # Convert 0.123 or 0,123 into what will be parsed as a decimal amount 0.12 or 0.13
+ amount.gsub!(/^(-)?(0[,.]\d\d)\d+$/, '\1\2')
+
+ segments = amount.scan(/^(.*?)(?:[\.\,](\d{1,2}))?$/).first
+
+ return ZERO_MONEY if segments.empty?
+
+ amount = segments[0].gsub(/[^-\d]/, '')
+ decimals = segments[1].to_s.ljust(2, '0')
+
+ "#{amount}.#{decimals}"
+ end
+end
33 lib/money_column.rb
@@ -0,0 +1,33 @@
+module MoneyColumn
+ def self.included(base)
+ base.extend(ClassMethods)
+ end
+
+ module ClassMethods
+
+ def money_column(*columns)
+
+ [columns].flatten.each do |name|
+ define_method(name) do
+ value = read_attribute(name)
+ value.blank? ? nil : Money.new(read_attribute(name))
+ end
+
+ define_method("#{name}_before_type_cast") do
+ send(name) && sprintf("%.2f", send(name))
+ end
+
+ define_method("#{name}=") do |value|
+ if value.blank?
+ write_attribute(name, nil)
+ nil
+ else
+ money = value.to_money
+ write_attribute(name, money.value)
+ money
+ end
+ end
+ end
+ end
+ end
+end
43 spec/core_extensions_spec.rb
@@ -0,0 +1,43 @@
+require File.dirname(__FILE__) + '/spec_helper'
+
+describe "an object supporting to_money", :shared => true do
+ it "should support to_money" do
+ @value.to_money.should == @money
+ end
+end
+
+describe Integer do
+ before(:each) do
+ @value = 1
+ @money = Money.new("1.00")
+ end
+
+ it_should_behave_like "an object supporting to_money"
+end
+
+describe Float do
+ before(:each) do
+ @value = 1.23
+ @money = Money.new("1.23")
+ end
+
+ it_should_behave_like "an object supporting to_money"
+end
+
+describe String do
+ before(:each) do
+ @value = "1.23"
+ @money = Money.new("1.23")
+ end
+
+ it_should_behave_like "an object supporting to_money"
+end
+
+describe BigDecimal do
+ before(:each) do
+ @value = BigDecimal.new("1.23")
+ @money = Money.new("1.23")
+ end
+
+ it_should_behave_like "an object supporting to_money"
+end
193 spec/money_parser_spec.rb
@@ -0,0 +1,193 @@
+require File.dirname(__FILE__) + '/spec_helper'
+
+describe MoneyParser do
+ describe "parsing of amounts with period decimal separator" do
+ before(:each) do
+ @parser = MoneyParser.new
+ end
+
+ it "should parse an empty string to $0" do
+ @parser.parse("").should == Money.new
+ end
+
+ it "should parse an invalid string to $0" do
+ @parser.parse("no money").should == Money.new
+ end
+
+ it "should parse a single digit integer string" do
+ @parser.parse("1").should == Money.new(1.00)
+ end
+
+ it "should parse a double digit integer string" do
+ @parser.parse("10").should == Money.new(10.00)
+ end
+
+ it "should parse an integer string amount with a leading $" do
+ @parser.parse("$1").should == Money.new(1.00)
+ end
+
+ it "should parse a float string amount" do
+ @parser.parse("1.37").should == Money.new(1.37)
+ end
+
+ it "should parse a float string amount with a leading $" do
+ @parser.parse("$1.37").should == Money.new(1.37)
+ end
+
+ it "should parse a float string with a single digit after the decimal" do
+ @parser.parse("10.0").should == Money.new(10.00)
+ end
+
+ it "should parse a float string with two digits after the decimal" do
+ @parser.parse("10.00").should == Money.new(10.00)
+ end
+
+ it "should parse the amount from an amount surrounded by whitespace and garbage" do
+ @parser.parse("Rubbish $1.00 Rubbish").should == Money.new(1.00)
+ end
+
+ it "should parse the amount from an amount surrounded by garbage" do
+ @parser.parse("Rubbish$1.00Rubbish").should == Money.new(1.00)
+ end
+
+ it "should parse a negative integer amount in the hundreds" do
+ @parser.parse("-100").should == Money.new(-100.00)
+ end
+
+ it "should parse an integer amount in the hundreds" do
+ @parser.parse("410").should == Money.new(410.00)
+ end
+
+ it "should parse a positive amount with a thousands separator" do
+ @parser.parse("100,000.00").should == Money.new(100_000.00)
+ end
+
+ it "should parse a negative amount with a thousands separator" do
+ @parser.parse("-100,000.00").should == Money.new(-100_000.00)
+ end
+
+ it "should parse negative $1.00" do
+ @parser.parse("-1.00").should == Money.new(-1.00)
+ end
+
+ it "should parse a negative cents amount" do
+ @parser.parse("-0.90").should == Money.new(-0.90)
+ end
+
+ it "should parse amount with 3 decimals and 0 dollar amount" do
+ @parser.parse("0.123").should == Money.new(0.12)
+ end
+
+ it "should parse negative amount with 3 decimals and 0 dollar amount" do
+ @parser.parse("-0.123").should == Money.new(-0.12)
+ end
+
+ it "should parse negative amount with multiple leading - signs" do
+ @parser.parse("--0.123").should == Money.new(-0.12)
+ end
+
+ it "should parse negative amount with multiple - signs" do
+ @parser.parse("--0.123--").should == Money.new(-0.12)
+ end
+ end
+
+ describe "parsing of amounts with comma decimal separator" do
+ before(:each) do
+ @parser = MoneyParser.new
+ end
+
+ it "should parse dollar amount $1,00 with leading $" do
+ @parser.parse("$1,00").should == Money.new(1.00)
+ end
+
+ it "should parse dollar amount $1,37 with leading $, and non-zero cents" do
+ @parser.parse("$1,37").should == Money.new(1.37)
+ end
+
+ it "should parse the amount from an amount surrounded by whitespace and garbage" do
+ @parser.parse("Rubbish $1,00 Rubbish").should == Money.new(1.00)
+ end
+
+ it "should parse the amount from an amount surrounded by garbage" do
+ @parser.parse("Rubbish$1,00Rubbish").should == Money.new(1.00)
+ end
+
+ it "should parse thousands amount" do
+ @parser.parse("1.000").should == Money.new(1000.00)
+ end
+
+ it "should parse negative hundreds amount" do
+ @parser.parse("-100,00").should == Money.new(-100.00)
+ end
+
+ it "should parse positive hundreds amount" do
+ @parser.parse("410,00").should == Money.new(410.00)
+ end
+
+ it "should parse a positive amount with a thousands separator" do
+ @parser.parse("100.000,00").should == Money.new(100_000.00)
+ end
+
+ it "should parse a negative amount with a thousands separator" do
+ @parser.parse("-100.000,00").should == Money.new(-100_000.00)
+ end
+
+ it "should parse amount with 3 decimals and 0 dollar amount" do
+ @parser.parse("0,123").should == Money.new(0.12)
+ end
+
+ it "should parse negative amount with 3 decimals and 0 dollar amount" do
+ @parser.parse("-0,123").should == Money.new(-0.12)
+ end
+ end
+
+ describe "parsing of decimal cents amounts from 0 to 10" do
+ before(:each) do
+ @parser = MoneyParser.new
+ end
+
+ it "should parse 50.0" do
+ @parser.parse("50.0").should == Money.new(50.00)
+ end
+
+ it "should parse 50.1" do
+ @parser.parse("50.1").should == Money.new(50.10)
+ end
+
+ it "should parse 50.2" do
+ @parser.parse("50.2").should == Money.new(50.20)
+ end
+
+ it "should parse 50.3" do
+ @parser.parse("50.3").should == Money.new(50.30)
+ end
+
+ it "should parse 50.4" do
+ @parser.parse("50.4").should == Money.new(50.40)
+ end
+
+ it "should parse 50.5" do
+ @parser.parse("50.5").should == Money.new(50.50)
+ end
+
+ it "should parse 50.6" do
+ @parser.parse("50.6").should == Money.new(50.60)
+ end
+
+ it "should parse 50.7" do
+ @parser.parse("50.7").should == Money.new(50.70)
+ end
+
+ it "should parse 50.8" do
+ @parser.parse("50.8").should == Money.new(50.80)
+ end
+
+ it "should parse 50.9" do
+ @parser.parse("50.9").should == Money.new(50.90)
+ end
+
+ it "should parse 50.10" do
+ @parser.parse("50.10").should == Money.new(50.10)
+ end
+ end
+end
257 spec/money_spec.rb
@@ -0,0 +1,257 @@
+require File.dirname(__FILE__) + '/spec_helper'
+
+describe Money do
+
+ before(:each) do
+ @money = Money.new
+ end
+
+ it "should be contructable with empty class method" do
+ Money.empty.should == @money
+ end
+
+ it "should return itself with to_money" do
+ @money.to_money.should equal(@money)
+ end
+
+ it "should default to 0 when constructed with no arguments" do
+ @money.should == Money.new(0.00)
+ end
+
+ it "should to_s as a float with 2 decimal places" do
+ @money.to_s.should == "0.00"
+ end
+
+ it "should be constructable with a BigDecimal" do
+ Money.new(BigDecimal.new("1.23")).should == Money.new(1.23)
+ end
+
+ it "should be constructable with a Fixnum" do
+ Money.new(3).should == Money.new(3.00)
+ end
+
+ it "should be construcatable with a Float" do
+ Money.new(3.00).should == Money.new(3.00)
+ end
+
+ it "should be addable" do
+ (Money.new(1.51) + Money.new(3.49)).should == Money.new(5.00)
+ end
+
+ it "should be able to add $0 + $0" do
+ (Money.new + Money.new).should == Money.new
+ end
+
+ it "should be subtractable" do
+ (Money.new(5.00) - Money.new(3.49)).should == Money.new(1.51)
+ end
+
+ it "should be subtractable to $0" do
+ (Money.new(5.00) - Money.new(5.00)).should == Money.new
+ end
+
+ it "should be substractable to a negative amount" do
+ (Money.new(0.00) - Money.new(1.00)).should == Money.new("-1.00")
+ end
+
+ it "should inspect to a presentable string" do
+ @money.inspect.should == "#<Money value:0.00>"
+ end
+
+ it "should be inspectable within an array" do
+ [@money].inspect.should == "[#<Money value:0.00>]"
+ end
+
+ it "should correctly support eql? as a value object" do
+ @money.should eql(Money.new)
+ end
+
+ it "should be addable with integer" do
+ (Money.new(1.33) + 1).should == Money.new(2.33)
+ end
+
+ it "should be addable with float" do
+ (Money.new(1.33) + 1.50).should == Money.new(2.83)
+ end
+
+ it "should be multipliable with an integer" do
+ (Money.new(1.00) * 55).should == Money.new(55.00)
+ end
+
+ it "should be multiplable with a float" do
+ (Money.new(1.00) * 1.50).should == Money.new(1.50)
+ end
+
+ it "should be multipliable by a cents amount" do
+ (Money.new(1.00) * 0.50).should == Money.new(0.50)
+ end
+
+ it "should be multipliable by a repeatable floating point number" do
+ (Money.new(24.00) * (1 / 30.0)).should == Money.new(0.80)
+ end
+
+ it "should round multiplication result with fractional penny of 5 or higher up" do
+ (Money.new(0.03) * 0.5).should == Money.new(0.02)
+ end
+
+ it "should round multiplication result with fractional penny of 4 or lower down" do
+ (Money.new(0.10) * 0.33).should == Money.new(0.03)
+ end
+
+ it "should be divisible by a fixnum" do
+ (Money.new(55.00) / 55).should == Money.new(1.00)
+ end
+
+ it "should be divisible by an integer" do
+ (Money.new(2.00) / 2).should == Money.new(1.00)
+ end
+
+ it "should round to the lowest cent value during division" do
+ (Money.new(2.00) / 3).should == Money.new(0.67)
+ end
+
+ it "should return cents in to_liquid" do
+ Money.new(1.00).to_liquid.should == 100
+ end
+
+ it "should return cents in to_json" do
+ Money.new(1.00).to_liquid.should == 100
+ end
+
+ it "should support absolute value" do
+ Money.new(-1.00).abs.should == Money.new(1.00)
+ end
+
+ it "should support to_i" do
+ Money.new(1.50).to_i.should == 1
+ end
+
+ it "should support to_f" do
+ Money.new(1.50).to_f.to_s.should == "1.5"
+ end
+
+ it "should be creatable from an integer value in cents" do
+ Money.from_cents(1950).should == Money.new(19.50)
+ end
+
+ it "should be creatable from an integer value of 0 in cents" do
+ Money.from_cents(0).should == Money.new
+ end
+
+ it "should be creatable from a float cents amount" do
+ Money.from_cents(1950.5).should == Money.new(19.51)
+ end
+
+ it "should raise when constructed with a NaN value" do
+ lambda{ Money.new( 0.0 / 0) }.should raise_error
+ end
+
+ it "should be comparable with non-money objects" do
+ @money.should_not == nil
+ end
+
+ describe "frozen with amount of $1" do
+ before(:each) do
+ @money = Money.new(1.00).freeze
+ end
+
+ it "should == $1" do
+ @money.should == Money.new(1.00)
+ end
+
+ it "should not == $2" do
+ @money.should_not == Money.new(2.00)
+ end
+
+ it "<=> $1 should be 0" do
+ (@money <=> Money.new(1.00)).should == 0
+ end
+
+ it "<=> $2 should be -1" do
+ (@money <=> Money.new(2.00)).should == -1
+ end
+
+ it "<=> $0.50 should equal 1" do
+ (@money <=> Money.new(0.50)).should == 1
+ end
+
+ it "should have the same hash value as $1" do
+ @money.hash.should == Money.new(1.00).hash
+ end
+
+ it "should not have the same hash value as $2" do
+ @money.hash.should == Money.new(1.00).hash
+ end
+
+ end
+
+ describe "with amount of $0" do
+ before(:each) do
+ @money = Money.new
+ end
+
+ it "should be zero" do
+ @money.should be_zero
+ end
+
+ it "should be greater than -$1" do
+ @money.should be > Money.new("-1.00")
+ end
+
+ it "should be greater than or equal to $0" do
+ @money.should be >= Money.new
+ end
+
+ it "should be less than or equal to $0" do
+ @money.should be <= Money.new
+ end
+
+ it "should be less than $1" do
+ @money.should be < Money.new(1.00)
+ end
+ end
+
+ describe "with amount of $1" do
+ before(:each) do
+ @money = Money.new(1.00)
+ end
+
+ it "should not be zero" do
+ @money.should_not be_zero
+ end
+
+ it "should have a decimal value = 1.00" do
+ @money.value.should == BigDecimal.new("1.00")
+ end
+
+ it "should have 100 cents" do
+ @money.cents.should == 100
+ end
+
+ it "should return cents as a Fixnum" do
+ @money.cents.should be_an_instance_of(Fixnum)
+ end
+
+ it "should be greater than $0" do
+ @money.should be > Money.new(0.00)
+ end
+
+ it "should be less than $2" do
+ @money.should be < Money.new(2.00)
+ end
+
+ it "should be equal to $1" do
+ @money.should == Money.new(1.00)
+ end
+ end
+
+ describe "with amount of $1 with created with 3 decimal places" do
+ before(:each) do
+ @money = Money.new(1.125)
+ end
+
+ it "should round 3rd decimal place" do
+ @money.value.should == BigDecimal.new("1.13")
+ end
+ end
+end
7 spec/spec.opts
@@ -0,0 +1,7 @@
+--colour
+--format
+progress
+--loadby
+mtime
+--reverse
+
1  spec/spec_helper.rb
@@ -0,0 +1 @@
+require File.dirname(__FILE__) + '/../lib/money'
Please sign in to comment.
Something went wrong with that request. Please try again.