Permalink
Browse files

Initial extraction.

  • Loading branch information...
0 parents commit b416d30e5a1abd6895773621266b31956a8ec212 @tobi committed Apr 21, 2010
Showing with 743 additions and 0 deletions.
  1. +25 −0 README
  2. +14 −0 Rakefile
  3. +4 −0 init.rb
  4. +3 −0 lib/money.rb
  5. +18 −0 lib/money/core_extensions.rb
  6. +112 −0 lib/money/money.rb
  7. +33 −0 lib/money/money_parser.rb
  8. +33 −0 lib/money_column.rb
  9. +43 −0 spec/core_extensions_spec.rb
  10. +193 −0 spec/money_parser_spec.rb
  11. +257 −0 spec/money_spec.rb
  12. +7 −0 spec/spec.opts
  13. +1 −0 spec/spec_helper.rb
@@ -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
+
@@ -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
@@ -0,0 +1,4 @@
+require 'money'
+require 'money_column'
+
+ActiveRecord::Base.send :include, MoneyColumn
@@ -0,0 +1,3 @@
+require File.dirname(__FILE__) + '/money/money'
+require File.dirname(__FILE__) + '/money/money_parser'
+require File.dirname(__FILE__) + '/money/core_extensions'
@@ -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
@@ -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
+
@@ -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
@@ -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
@@ -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
Oops, something went wrong.

0 comments on commit b416d30

Please sign in to comment.