servicemerchant 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. data/MIT-LICENSE.txt +20 -0
  2. data/README.txt +231 -0
  3. data/Rakefile +122 -0
  4. data/demo.rb +69 -0
  5. data/recurring_billing/lib/am_extensions.rb +1 -0
  6. data/recurring_billing/lib/am_extensions/paypal_extension.rb +170 -0
  7. data/recurring_billing/lib/dependencies.rb +14 -0
  8. data/recurring_billing/lib/gateways.rb +5 -0
  9. data/recurring_billing/lib/gateways/authorize_net.rb +103 -0
  10. data/recurring_billing/lib/gateways/paypal.rb +124 -0
  11. data/recurring_billing/lib/recurring_billing.rb +130 -0
  12. data/recurring_billing/lib/recurring_billing.rdoc +87 -0
  13. data/recurring_billing/lib/utils.rb +81 -0
  14. data/recurring_billing/test/fixtures.yml +33 -0
  15. data/recurring_billing/test/remote/authorize_net_test.rb +36 -0
  16. data/recurring_billing/test/remote/paypal_test.rb +46 -0
  17. data/recurring_billing/test/remote/recurring_billing_test.rb +41 -0
  18. data/recurring_billing/test/test_helper.rb +153 -0
  19. data/recurring_billing/test/unit/authorize_net_gateway_class_test.rb +42 -0
  20. data/recurring_billing/test/unit/paypal_gateway_class_test.rb +23 -0
  21. data/recurring_billing/test/unit/recurring_billing_gateway_class_test.rb +35 -0
  22. data/recurring_billing/test/unit/utils_test.rb +17 -0
  23. data/subscription_management/Rakefile +29 -0
  24. data/subscription_management/lib/models/subscription.rb +9 -0
  25. data/subscription_management/lib/models/subscription_profile.rb +4 -0
  26. data/subscription_management/lib/subscription_management.rb +326 -0
  27. data/subscription_management/samples/backpack.yml +101 -0
  28. data/subscription_management/samples/basecamp.yml +71 -0
  29. data/subscription_management/samples/brainkeeper.yml +90 -0
  30. data/subscription_management/samples/campfire.yml +74 -0
  31. data/subscription_management/samples/clickandpledge.yml +24 -0
  32. data/subscription_management/samples/demo.rb +19 -0
  33. data/subscription_management/samples/elm.yml +174 -0
  34. data/subscription_management/samples/freshbooks.yml +78 -0
  35. data/subscription_management/samples/highrise.yml +100 -0
  36. data/subscription_management/samples/presets.yml +10 -0
  37. data/subscription_management/samples/tariff.outline.yml +0 -0
  38. data/subscription_management/samples/taxes.yml +21 -0
  39. data/subscription_management/subscription_management.rb +7 -0
  40. data/subscription_management/tasks/schema.rb +50 -0
  41. data/subscription_management/test/connection.rb +10 -0
  42. data/subscription_management/test/remote/subscription_management_test.rb +112 -0
  43. data/subscription_management/test/test_helper.rb +84 -0
  44. data/subscription_management/test/unit/subscription_management_test.rb +40 -0
  45. data/tracker/README +12 -0
  46. data/tracker/Rakefile +40 -0
  47. data/tracker/db/migrations/empty-directory +0 -0
  48. data/tracker/demo.rb +12 -0
  49. data/tracker/lib/models/recurring_payment_profile.rb +134 -0
  50. data/tracker/lib/models/transaction.rb +19 -0
  51. data/tracker/lib/recurring_billing_extension.rb +103 -0
  52. data/tracker/lib/recurring_billing_extension.rdoc +34 -0
  53. data/tracker/tasks/schema.rb +66 -0
  54. data/tracker/test/connection.rb +10 -0
  55. data/tracker/test/recurring_payment_profile.rb +35 -0
  56. data/tracker/test/remote/authorize_net_test.rb +68 -0
  57. data/tracker/test/remote/paypal_test.rb +115 -0
  58. data/tracker/test/test_helper.rb +87 -0
  59. data/tracker/test/unit/recurring_payment_profile_test.rb +62 -0
  60. data/tracker/tracker.rb +10 -0
  61. data/vendor/money-1.7.1/MIT-LICENSE +20 -0
  62. data/vendor/money-1.7.1/README +75 -0
  63. data/vendor/money-1.7.1/lib/bank/no_exchange_bank.rb +9 -0
  64. data/vendor/money-1.7.1/lib/bank/variable_exchange_bank.rb +30 -0
  65. data/vendor/money-1.7.1/lib/money.rb +29 -0
  66. data/vendor/money-1.7.1/lib/money/core_extensions.rb +26 -0
  67. data/vendor/money-1.7.1/lib/money/money.rb +209 -0
  68. data/vendor/money-1.7.1/lib/support/cattr_accessor.rb +57 -0
  69. metadata +153 -0
@@ -0,0 +1,10 @@
1
+ # As we're not using Rails in tests, we have to establish DB conneciton ourselves
2
+ require 'rubygems'
3
+ gem 'activerecord'
4
+ require 'activerecord'
5
+ gem 'sqlite3-ruby'
6
+
7
+ ActiveRecord::Base.establish_connection(
8
+ :adapter => "sqlite3",
9
+ :database => (File.dirname(__FILE__) + "/../db/test.db")
10
+ )
@@ -0,0 +1,35 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ class RandomRecurringProfileTest < Test::Unit::TestCase
4
+
5
+ def setup
6
+ cred = fixtures(:paypal)
7
+ assert @gw = RecurringBilling::PaypalGateway.new(cred)
8
+ @card = credit_card(number='4532130086825928')
9
+ end
10
+
11
+ def test_crud_recurring_payment
12
+ payment_options = {
13
+ :subscription_name => 'Test Subscription 1337',
14
+ :order => {:invoice_number => '407933'}
15
+ }
16
+ recurring_options = {
17
+ :start_date => Date.today + 1,
18
+ :end_date => Date.today + 290,
19
+ :interval => '1m'
20
+ }
21
+
22
+ billing_id = @gw.create_with_persist(Money.us_dollar(15), @card, payment_options=payment_options, recurring_options=recurring_options)
23
+ print "\n\nCreate:\n"
24
+ print @gw.last_response.inspect
25
+ payment_options[:order] = {:invoice_number => '407934'}
26
+ @gw.update_with_persist(billing_id, Money.us_dollar(16), @card, payment_options=payment_options)
27
+ print "\n\nUpdate:\n"
28
+ print @gw.last_response.inspect
29
+ @gw.delete_with_persist(billing_id)
30
+ print "\n\nDelete:\n"
31
+ print @gw.last_response.inspect
32
+ end
33
+
34
+ end
35
+
@@ -0,0 +1,68 @@
1
+ require File.dirname(__FILE__) + '/../test_helper'
2
+ require "tracker"
3
+
4
+ class AuthorizeNetGatewayRemoteTest < Test::Unit::TestCase
5
+
6
+ def setup
7
+ credentials = fixtures(:authorize_net)
8
+ assert @gw = RecurringBilling::AuthorizeNetGateway.new(credentials)
9
+ @card = credit_card()
10
+ end
11
+
12
+ def test_crud_recurring_payment
13
+ payment_options = {
14
+ :subscription_name => 'Random subscription',
15
+ :order => {:invoice_number => 'ODMX31337'}
16
+ }
17
+ recurring_options = {
18
+ :start_date => Date.today,
19
+ :occurrences => 10,
20
+ :interval => '10d'
21
+ }
22
+
23
+ sum = rand(50000)+120
24
+ billing_id = @gw.create(Money.us_dollar(sum), @card, payment_options, recurring_options)
25
+ assert @gw.last_response.success?
26
+
27
+ payment_options[:order] = {:invoice_number => 'ODMX17532'}
28
+
29
+ another_sum = rand(50000)+120
30
+ @gw.update(billing_id, Money.us_dollar(another_sum), @card, payment_options, {})
31
+ assert @gw.last_response.success?
32
+
33
+ assert_raise NotImplementedError do; @gw.inquiry(billing_id); end
34
+
35
+ @gw.delete(billing_id)
36
+ assert @gw.last_response.success?
37
+ profile = RecurringPaymentProfile.find_by_gateway_reference(billing_id)
38
+ assert_equal 'deleted', profile.status
39
+
40
+ assert_raise StandardError do;@gw.update(billing_id, Money.us_dollar(another_sum), @card, payment_options, {});end;
41
+ end
42
+
43
+ def test_update_or_recreate
44
+ payment_options = {
45
+ :subscription_name => 'Random subscription',
46
+ :order => {:invoice_number => 'ODMX31337'}
47
+ }
48
+ recurring_options = {
49
+ :start_date => Date.today,
50
+ :occurrences => 10,
51
+ :interval => '10d'
52
+ }
53
+
54
+ sum = rand(50000)+120
55
+ billing_id = @gw.create(Money.us_dollar(sum), @card, payment_options, recurring_options)
56
+ assert @gw.last_response.success?
57
+
58
+ payment_options[:order] = {:invoice_number => 'ODMX17532'}
59
+
60
+ another_sum = rand(50000)+120
61
+ @gw.update_or_recreate(billing_id, {:amount => Money.us_dollar(another_sum)})
62
+ assert @gw.last_response.success?
63
+
64
+ @gw.update_or_recreate(billing_id, {:card => @card, :start_date => Date.new(2009, 7, 8)})
65
+ assert @gw.last_response.success?
66
+ end
67
+
68
+ end
@@ -0,0 +1,115 @@
1
+ require File.dirname(__FILE__) + '/../test_helper'
2
+
3
+ require "mocha"
4
+ require "tracker"
5
+
6
+ class PaypalRemoteTest < Test::Unit::TestCase
7
+
8
+ def setup
9
+ cred = fixtures(:paypal)
10
+ assert @gw = RecurringBilling::PaypalGateway.new(cred)
11
+ @card = credit_card()
12
+ @card2 = credit_card('4929838635250031', {:first_name => 'James', :last_name => 'Lueser'})
13
+ @card_bogus = credit_card("ISMELLLIKEBOGUS")
14
+ end
15
+
16
+ def test_inquiry_updates_tracker
17
+ payment_options = {
18
+ :subscription_name => 'Test Subscription 1337',
19
+ :order => {:invoice_number => '407933'}
20
+ }
21
+ recurring_options = {
22
+ :start_date => Date.today + 1,
23
+ :end_date => Date.today + 290,
24
+ :interval => '1m'
25
+ }
26
+
27
+ billing_id = @gw.create(Money.us_dollar(15), @card, payment_options=payment_options, recurring_options=recurring_options)
28
+ assert @gw.last_response.success?
29
+
30
+ profile = RecurringPaymentProfile.find_by_gateway_reference(billing_id)
31
+ assert_equal 'active', profile.status
32
+ assert_not_equal -1, profile.outstanding_balance
33
+ assert_not_equal -1, profile.complete_payments_count
34
+ assert_not_equal -1, profile.failed_payments_count
35
+ assert_not_equal -1, profile.remaining_payments_count
36
+
37
+ # Mock result of original inquire methods
38
+ result_mock = {'profile_status' => 'verified',
39
+ 'outstanding_balance' => ::Money.new(-100),
40
+ 'number_cycles_completed'=> -1,
41
+ 'failed_payment_count' => -1,
42
+ 'number_cycles_remaining'=> -1}
43
+
44
+ @gw.methods.include? :inquiry_without_persist
45
+ @gw.expects(:inquiry_without_persist).returns(result_mock)
46
+ result = @gw.inquiry(billing_id)
47
+ assert @gw.last_response.success?
48
+
49
+ assert_equal result_mock, result
50
+
51
+ # Get profile and check whether data is updated
52
+ profile = RecurringPaymentProfile.find_by_gateway_reference(billing_id)
53
+ assert_equal 'verified', profile.status
54
+ assert_equal -100, profile.outstanding_balance
55
+ assert_equal -1, profile.complete_payments_count
56
+ assert_equal -1, profile.failed_payments_count
57
+ assert_equal -1, profile.remaining_payments_count
58
+ end
59
+
60
+ def test_create_update_failure
61
+ payment_options = {
62
+ :subscription_name => 'Unsuccessful payment',
63
+ :order => {:invoice_number => '032895'}
64
+ }
65
+ recurring_options = {
66
+ :start_date => Date.today + 1,
67
+ :occurrences => 402,
68
+ :interval => '1w'
69
+ }
70
+
71
+ new_recurring_options = {
72
+ :pay_on_day_x => 3
73
+ }
74
+
75
+ token_sum = rand(50000)+120
76
+ @gw.create(Money.us_dollar(token_sum), @card_bogus, payment_options=payment_options, recurring_options=recurring_options)
77
+ assert !@gw.last_response.success?
78
+ assert RecurringPaymentProfile.find_by_net_amount(Money.us_dollar(token_sum).cents).nil?
79
+
80
+ new_token_sum = rand(50000)+120
81
+ billing_id = @gw.create(Money.us_dollar(new_token_sum), @card, payment_options=payment_options, recurring_options=recurring_options)
82
+ assert @gw.last_response.success?
83
+ assert_equal profile = RecurringPaymentProfile.find_by_net_amount(Money.us_dollar(new_token_sum).cents), RecurringPaymentProfile.find_by_gateway_reference(billing_id)
84
+
85
+ another_token_sum = rand(50000)+120
86
+ @gw.update(billing_id, Money.us_dollar(another_token_sum), @card, {}, new_recurring_options)
87
+ assert_not_equal profile.updated_at, (profile2 = RecurringPaymentProfile.find_by_gateway_reference(billing_id)).updated_at #meaning the profile hadn't been updated
88
+ assert_equal profile2.amount, Money.us_dollar(another_token_sum).cents
89
+
90
+
91
+ @gw.update(billing_id, Money.us_dollar(new_token_sum), @card_bogus, {}, {})
92
+ assert_equal profile2.updated_at, RecurringPaymentProfile.find_by_gateway_reference(billing_id).updated_at #meaning the profile hadn't been updated
93
+ end
94
+
95
+ def test_update_or_create
96
+ payment_options = {
97
+ :subscription_name => 'Random subscription',
98
+ :order => {:invoice_number => 'ODMX31337'}
99
+ }
100
+ recurring_options = {
101
+ :start_date => Date.today,
102
+ :occurrences => 10,
103
+ :interval => '10 w'
104
+ }
105
+
106
+ sum = rand(50000)+120
107
+ billing_id = @gw.create(Money.us_dollar(sum), @card, payment_options, recurring_options)
108
+ assert @gw.last_response.success?
109
+
110
+ another_sum = rand(50000)+120
111
+ @gw.update_or_recreate(billing_id, {:card => @card2, :occurrences => 20})
112
+ assert @gw.last_response.success?
113
+ end
114
+
115
+ end
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'test/unit'
5
+ require 'activesupport'
6
+
7
+ silence_warnings do
8
+ require 'active_merchant'
9
+ end
10
+
11
+ require File.dirname(__FILE__) + "/connection"
12
+
13
+ $: << File.dirname(__FILE__) + "/../" # Tracker root
14
+
15
+ require 'tracker'
16
+
17
+ # Turn off invalid certificate crashes
18
+ require 'openssl'
19
+ silence_warnings do
20
+ OpenSSL::SSL::VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE
21
+ end
22
+
23
+ ActiveMerchant::Billing::Base.mode = :test
24
+
25
+ module Test
26
+ module Unit
27
+ class TestCase
28
+
29
+
30
+ DEFAULT_CREDENTIALS = File.dirname(__FILE__) + '/fixtures.yml'
31
+
32
+ private
33
+ def credit_card(number = '4242424242424242', options = {})
34
+ defaults = {
35
+ :number => number,
36
+ :month => 9,
37
+ :year => Time.now.year + 1,
38
+ :first_name => 'John',
39
+ :last_name => 'Doe',
40
+ :verification_value => '123',
41
+ :type => 'visa'
42
+ }.update(options)
43
+
44
+ ActiveMerchant::Billing::CreditCard.new(defaults)
45
+ end
46
+
47
+ def address(options = {})
48
+ {
49
+ :name => 'John Doe',
50
+ :address1 => '1234 My Street',
51
+ :address2 => 'Apt 1',
52
+ :company => 'Widgets Inc',
53
+ :city => 'Ottawa',
54
+ :state => 'ON',
55
+ :zip => 'K1C2N6',
56
+ :country => 'CA',
57
+ :phone => '(555)555-5555'
58
+ }.update(options)
59
+ end
60
+
61
+ def all_fixtures
62
+ @@fixtures ||= load_fixtures
63
+ end
64
+
65
+ def fixtures(key)
66
+ data = all_fixtures[key] || raise(StandardError, "No fixture data was found for '#{key}'")
67
+
68
+ data.dup
69
+ end
70
+
71
+ def load_fixtures
72
+ file = DEFAULT_CREDENTIALS
73
+ yaml_data = YAML.load(File.read(file))
74
+ symbolize_keys(yaml_data)
75
+
76
+ yaml_data
77
+ end
78
+
79
+ def symbolize_keys(hash)
80
+ return unless hash.is_a?(Hash)
81
+
82
+ hash.symbolize_keys!
83
+ hash.each{|k,v| symbolize_keys(v)}
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,62 @@
1
+ require File.dirname(__FILE__) + '/../test_helper'
2
+
3
+ require 'money'
4
+
5
+ class RecurringPaymentProfileTest < Test::Unit::TestCase
6
+
7
+ def setup
8
+ @profile = ::RecurringPaymentProfile.new
9
+ end
10
+
11
+ def test_net_money_assigns
12
+ @profile.net_money=Money.new(333, currency='CAD')
13
+ assert_equal 333, @profile.net_amount
14
+ assert_equal 'CAD', @profile.currency
15
+ end
16
+
17
+ def test_taxes_money_assigns
18
+ @profile.taxes_money=Money.new(444, currency='RUR')
19
+ assert_equal 444, @profile.taxes_amount
20
+ assert_equal 'RUR', @profile.currency
21
+ end
22
+
23
+ def test_net_money_fails
24
+ @profile.taxes_money=Money.new(333, currency='CAD')
25
+ assert_raise ArgumentError do; @profile.net_money=Money.new(444, currency='RUR'); end
26
+ end
27
+
28
+ def test_taxes_money_fails
29
+ @profile.net_money=Money.new(333, currency='CAD')
30
+ assert_raise ArgumentError do; @profile.taxes_money=Money.new(444, currency='RUR'); end
31
+ end
32
+
33
+ def test_mask_card_number
34
+ assert_equal 'abba', @profile.mask_card_number('abba')
35
+ assert_equal 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXched', @profile.mask_card_number('whatisthethingthatcannotbetouched')
36
+ end
37
+
38
+ def test_parse_and_set_card
39
+ card = credit_card(number='1111111111', options={:type => 'master', :year => 2003, :month => 3, :first_name => 'Name', :last_name => 'Surname'})
40
+ @profile.parse_and_set_card(card)
41
+ assert_equal 'master', @profile.card_type
42
+ assert_equal 'Name Surname, MASTER, XXXXXX1111, exp. 2003-03', @profile.card_owner_memo
43
+ end
44
+
45
+ def test_parse_and_set_card_with_hint
46
+ card = credit_card(number='1111111111', options={:type => 'master'})
47
+ @profile.parse_and_set_card(card, 'this is my hint')
48
+ assert_equal 'master', @profile.card_type
49
+ assert_equal 'this is my hint', @profile.card_owner_memo
50
+ end
51
+
52
+ def test_card_exp_date
53
+ card = credit_card(number='4242424242424242', options={:year => 2001, :month => 1})
54
+ assert_equal '2001-01', @profile.card_exp_date(card)
55
+ end
56
+
57
+ def test_no_obsolete_fields
58
+ assert_raise NoMethodError do; @profile.payment_offset = 0; end
59
+ assert_raise NoMethodError do; @profile.payments_start_on = Date.today; end
60
+ end
61
+
62
+ end
@@ -0,0 +1,10 @@
1
+ #TODO: Use auto-loading from activesupport
2
+
3
+ require 'rubygems'
4
+ require 'activerecord'
5
+
6
+ $: << File.dirname(__FILE__) + "/../vendor/money-1.7.1/lib"
7
+
8
+ require File.dirname(__FILE__) + '/lib/models/recurring_payment_profile'
9
+ require File.dirname(__FILE__) + '/lib/models/transaction'
10
+ require File.dirname(__FILE__) + '/lib/recurring_billing_extension'
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2005 Tobias Lutke
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,75 @@
1
+ == Money class
2
+
3
+ This money class is based on the example from the ActiveRecord doc:
4
+ http://api.rubyonrails.org/classes/ActiveRecord/Aggregations/ClassMethods.html
5
+
6
+ Its in production use at http://www.snowdevil.ca and I haven't found any major issues
7
+ so far.
8
+ The main reason to open source it is because It might be useful to other people and
9
+ I hope i'll get some feedback on how to improve the class.
10
+
11
+ I bundled the exporter with the money class since some tests depend on it and I figured
12
+ that most applications which need to deal with Money also need to deal with proper
13
+ exporting.
14
+
15
+ == Download
16
+
17
+ Preferred method of installation is gem:
18
+
19
+ gem install --source http://dist.leetsoft.com money
20
+
21
+ Alternatively you can get the library packed
22
+
23
+ http://dist.leetsoft.com/pkg/
24
+
25
+ == Usage
26
+
27
+ Use the compose_of helper to let active record deal with embedding the money
28
+ object in your models. The following example requires a cents and a currency field.
29
+
30
+ class ProductUnit < ActiveRecord::Base
31
+ belongs_to :product
32
+ composed_of :price, :class_name => "Money", :mapping => [%w(cents cents) %(currency currency)]
33
+
34
+ private
35
+ validate :cents_not_zero
36
+
37
+ def cents_not_zero
38
+ errors.add("cents", "cannot be zero or less") unless cents > 0
39
+ end
40
+
41
+ validates_presence_of :sku, :currency
42
+ validates_uniqueness_of :sku
43
+ end
44
+
45
+ == Class configuration
46
+
47
+ Two const class variables are available to tailor Money to your needs.
48
+ If you don't need currency exchange at all, just ignore those.
49
+
50
+ === Default Currency
51
+
52
+ By default Money defaults to USD as its currency. This can be overwritten using
53
+
54
+ Money.default_currency = "CAD"
55
+
56
+ If you use rails, the environment.rb is a very good place to put this.
57
+
58
+ === Currency Exchange
59
+
60
+ The second parameter is a bit more complex. It lets you provide your own implementation of the
61
+ currency exchange service. By default Money throws an exception when trying to call .exchange_to.
62
+
63
+ A second minimalist implementation is provided which lets you supply custom exchange rates:
64
+
65
+ Money.bank = VariableExchangeBank.new
66
+ Money.bank.add_rate("USD", "CAD", 1.24515)
67
+ Money.bank.add_rate("CAD", "USD", 0.803115)
68
+ Money.us_dollar(100).exchange_to("CAD") => Money.ca_dollar(124)
69
+ Money.ca_dollar(100).exchange_to("USD") => Money.us_dollar(80)
70
+
71
+ There is nothing stopping you from creating bank objects which scrape www.xe.com for the current rates or just return rand(2)
72
+
73
+ == Code
74
+
75
+ If you have any improvements please email them to tobi [at] leetsoft.com