merchant_sidekick 0.4.2

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 (63) hide show
  1. data/.gitignore +12 -0
  2. data/Changelog.md +38 -0
  3. data/Gemfile +2 -0
  4. data/MIT-LICENSE +19 -0
  5. data/README.md +88 -0
  6. data/Rakefile +10 -0
  7. data/lib/merchant_sidekick.rb +45 -0
  8. data/lib/merchant_sidekick/active_merchant/credit_card_payment.rb +117 -0
  9. data/lib/merchant_sidekick/active_merchant/gateways/authorize_net_gateway.rb +26 -0
  10. data/lib/merchant_sidekick/active_merchant/gateways/base.rb +29 -0
  11. data/lib/merchant_sidekick/active_merchant/gateways/bogus_gateway.rb +19 -0
  12. data/lib/merchant_sidekick/active_merchant/gateways/paypal_gateway.rb +43 -0
  13. data/lib/merchant_sidekick/addressable/address.rb +400 -0
  14. data/lib/merchant_sidekick/addressable/addressable.rb +353 -0
  15. data/lib/merchant_sidekick/buyer.rb +99 -0
  16. data/lib/merchant_sidekick/gateway.rb +81 -0
  17. data/lib/merchant_sidekick/install.rb +19 -0
  18. data/lib/merchant_sidekick/invoice.rb +179 -0
  19. data/lib/merchant_sidekick/line_item.rb +128 -0
  20. data/lib/merchant_sidekick/migrations/addressable.rb +47 -0
  21. data/lib/merchant_sidekick/migrations/billing.rb +100 -0
  22. data/lib/merchant_sidekick/migrations/shopping_cart.rb +28 -0
  23. data/lib/merchant_sidekick/money.rb +38 -0
  24. data/lib/merchant_sidekick/order.rb +244 -0
  25. data/lib/merchant_sidekick/payment.rb +59 -0
  26. data/lib/merchant_sidekick/purchase_invoice.rb +180 -0
  27. data/lib/merchant_sidekick/purchase_order.rb +350 -0
  28. data/lib/merchant_sidekick/railtie.rb +7 -0
  29. data/lib/merchant_sidekick/sales_invoice.rb +56 -0
  30. data/lib/merchant_sidekick/sales_order.rb +122 -0
  31. data/lib/merchant_sidekick/sellable.rb +88 -0
  32. data/lib/merchant_sidekick/seller.rb +93 -0
  33. data/lib/merchant_sidekick/shopping_cart/cart.rb +225 -0
  34. data/lib/merchant_sidekick/shopping_cart/line_item.rb +152 -0
  35. data/lib/merchant_sidekick/version.rb +3 -0
  36. data/merchant_sidekick.gemspec +37 -0
  37. data/spec/address_spec.rb +153 -0
  38. data/spec/addressable_spec.rb +250 -0
  39. data/spec/buyer_spec.rb +203 -0
  40. data/spec/cart_line_item_spec.rb +58 -0
  41. data/spec/cart_spec.rb +213 -0
  42. data/spec/config/merchant_sidekick.yml +10 -0
  43. data/spec/credit_card_payment_spec.rb +175 -0
  44. data/spec/fixtures/addresses.yml +97 -0
  45. data/spec/fixtures/line_items.yml +18 -0
  46. data/spec/fixtures/orders.yml +24 -0
  47. data/spec/fixtures/payments.yml +17 -0
  48. data/spec/fixtures/products.yml +12 -0
  49. data/spec/fixtures/users.yml +11 -0
  50. data/spec/gateway_spec.rb +136 -0
  51. data/spec/invoice_spec.rb +79 -0
  52. data/spec/line_item_spec.rb +65 -0
  53. data/spec/order_spec.rb +85 -0
  54. data/spec/payment_spec.rb +14 -0
  55. data/spec/purchase_invoice_spec.rb +70 -0
  56. data/spec/purchase_order_spec.rb +191 -0
  57. data/spec/sales_invoice_spec.rb +58 -0
  58. data/spec/sales_order_spec.rb +107 -0
  59. data/spec/schema.rb +28 -0
  60. data/spec/sellable_spec.rb +34 -0
  61. data/spec/seller_spec.rb +201 -0
  62. data/spec/spec_helper.rb +255 -0
  63. metadata +201 -0
@@ -0,0 +1,99 @@
1
+ module MerchantSidekick
2
+ module Buyer
3
+
4
+ def self.included(mod)
5
+ mod.extend(ClassMethods)
6
+ end
7
+
8
+ module ClassMethods
9
+
10
+ # Defines helper methods for a person buying items.
11
+ #
12
+ # E.g.
13
+ #
14
+ # class Client < ActiveRecord::Base
15
+ # acts_as_buyer
16
+ # ...
17
+ # end
18
+ #
19
+ # # Simple purchase
20
+ # # => @client.purchase @products
21
+ #
22
+ # # Purchase referencing a seller
23
+ # # => @client.purchase @products, :from => @merchant
24
+ #
25
+ # # Same as above
26
+ # # => @client.purchase_from @merchant, @products
27
+ #
28
+ def acts_as_buyer
29
+ include MerchantSidekick::Buyer::InstanceMethods
30
+ has_many :orders, :as => :buyer, :dependent => :destroy, :class_name => "::MerchantSidekick::Order"
31
+ has_many :invoices, :as => :buyer, :dependent => :destroy, :class_name => "::MerchantSidekick::Invoice"
32
+ has_many :purchase_orders, :as => :buyer, :class_name => "::MerchantSidekick::PurchaseOrder"
33
+ has_many :purchase_invoices, :as => :buyer, :class_name => "::MerchantSidekick::PurchaseInvoice"
34
+ end
35
+ end
36
+
37
+ module InstanceMethods
38
+
39
+ # like purchase but forces the seller parameter, instead of
40
+ # taking it as a :seller option
41
+ def purchase_from(seller, *arguments)
42
+ purchase(arguments, :from => seller)
43
+ end
44
+
45
+ # purchase creates a purchase order based on
46
+ # the given sellables, e.g. product, or basically
47
+ # anything that has a price attribute.
48
+ #
49
+ # E.g.
50
+ #
51
+ # buyer.purchase(product, :seller => seller)
52
+ #
53
+ def purchase(*arguments)
54
+ sellables = []
55
+ options = default_purchase_options
56
+
57
+ # distinguish between options and attributes
58
+ arguments = arguments.flatten
59
+ arguments.each do |argument|
60
+ case argument.class.name
61
+ when 'Hash'
62
+ options.merge! argument
63
+ else
64
+ sellables << (argument.is_a?(MerchantSidekick::ShoppingCart::Cart) ? argument.line_items : argument)
65
+ end
66
+ end
67
+ sellables.flatten!
68
+ sellables.reject! {|s| s.blank?}
69
+
70
+ raise ArgumentError.new("No sellable (e.g. product) model provided") if sellables.empty?
71
+ raise ArgumentError.new("Sellable models must have a :price") unless sellables.all? {|sellable| sellable.respond_to? :price}
72
+
73
+ self.purchase_orders.build do |po|
74
+ po.buyer = self
75
+ po.seller = options[:from]
76
+ po.build_addresses
77
+ sellables.each do |sellable|
78
+ if sellable && sellable.respond_to?(:before_add_to_order)
79
+ sellable.send(:before_add_to_order, self)
80
+ sellable.reload unless sellable.new_record?
81
+ end
82
+ li = LineItem.new(:sellable => sellable, :order => po)
83
+ po.line_items.push(li)
84
+ sellable.send(:after_add_to_order, self) if sellable && sellable.respond_to?(:after_add_to_order)
85
+ end
86
+ self
87
+ end
88
+ end
89
+
90
+ protected
91
+
92
+ # override in model, e.g. :from => @merchant
93
+ def default_purchase_options
94
+ {}
95
+ end
96
+
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,81 @@
1
+ # Base class for all merchant sidekick gateway implementations.
2
+ module MerchantSidekick
3
+
4
+ class << self
5
+ def default_gateway
6
+ MerchantSidekick::Gateway.default_gateway
7
+ end
8
+
9
+ def default_gateway=(value)
10
+ MerchantSidekick::Gateway.default_gateway = value
11
+ end
12
+ end
13
+
14
+ class Gateway
15
+ cattr_accessor :config_path
16
+ cattr_accessor :config_file_name
17
+ @@config_file_name = "merchant_sidekick.yml"
18
+ cattr_accessor :config
19
+ cattr_accessor :default_gateway # -> sets default gateway as instance or class (symbol) optional
20
+ cattr_accessor :gateway # -> caches gateway instance in decendants, e.g. in PaypalGateway.gateway
21
+
22
+ class << self
23
+
24
+ # Returns the gateway type name derived from the class name
25
+ # independent of the module name, e.g. :authorize_net_gateway
26
+ def type
27
+ name.split("::").last ? name.split("::").last.underscore.to_sym : name.underscore.to_sym
28
+ end
29
+
30
+ def config_path(file_name = nil)
31
+ unless @@config_path
32
+ @@config_path = "#{Rails.root}/config/#{file_name || config_file_name}"
33
+ end
34
+ @@config_path
35
+ end
36
+
37
+ # Returns configuration hash. By default the configuration is read from
38
+ # a YAML file from the Rails config/merchant_sidekick.yml path.
39
+ #
40
+ # E.g.
41
+ #
42
+ # # config/merchant_sidekick.yml
43
+ # development:
44
+ # login_id: foo
45
+ # transaction_key: bar
46
+ # mode: test
47
+ # production:
48
+ # ...
49
+ #
50
+ # or
51
+ #
52
+ # # config/merchant_sidekick.yml
53
+ # development:
54
+ # authorize_net_gateway:
55
+ # login_id: foo
56
+ # transaction_key: bar
57
+ # mode: test
58
+ # paypal_gateway:
59
+ # api_username: seller_XYZ_biz_api1.example.com
60
+ # api_password: ABCDEFG123456789
61
+ # signature: AsPC9BjkCyDFQXbStoZcgqH3hpacAX3IenGazd35.nEnXJKR9nfCmJDu
62
+ # pem_file_name: config/paypal.pem
63
+ # mode: test
64
+ # production:
65
+ # ...
66
+ #
67
+ def config
68
+ unless @@config
69
+ @@config = YAML.load_file(config_path)[Rails.env].symbolize_keys
70
+ @@config = @@config[type].symbolize_keys if @@config[type]
71
+ end
72
+ @@config
73
+ end
74
+
75
+ def default_gateway
76
+ @@default_gateway || raise("No gateway instance assigned, try e.g. MerchantSidekick::Gateway.default_gateway = ActiveMerchant::Billing::BogusGateway.new")
77
+ end
78
+
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,19 @@
1
+ require 'rails/generators'
2
+ require "rails/generators/active_record"
3
+
4
+ module MerchantSidekick
5
+ class Install < Rails::Generators::Base
6
+ include Rails::Generators::Migration
7
+ extend ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path('../migrations', __FILE__)
10
+
11
+ # Copies the migration template to db/migrate.
12
+ def copy_files(*args)
13
+ migration_template 'billing.rb', 'db/migrate/create_merchant_sidekick_billing_tables.rb'
14
+ migration_template 'shopping_cart.rb', 'db/migrate/create_merchant_sidekick_shopping_cart_tables.rb'
15
+ migration_template 'addressable.rb', 'db/migrate/create_merchant_sidekick_addressable_tables.rb'
16
+ end
17
+
18
+ end
19
+ end
@@ -0,0 +1,179 @@
1
+ # Baseclass for in- and outbound invoices.
2
+ module MerchantSidekick
3
+ class Invoice < ActiveRecord::Base
4
+ include AASM
5
+ # include ActionView::Helpers::TextHelper
6
+ self.table_name = "invoices"
7
+
8
+ attr_accessor :authorization
9
+
10
+ belongs_to :seller, :polymorphic => true
11
+ belongs_to :buyer, :polymorphic => true
12
+ has_many :line_items, :class_name => "MerchantSidekick::LineItem"
13
+ belongs_to :order, :class_name => "MerchantSidekick::Order"
14
+ has_many :payments, :as => :payable, :dependent => :destroy, :class_name => "MerchantSidekick::Payment"
15
+
16
+ money :net_amount, :cents => :net_cents, :currency => :currency
17
+ money :tax_amount, :cents => :tax_cents, :currency => :currency
18
+ money :gross_amount, :cents => :gross_cents, :currency => :currency
19
+ has_address :origin, :billing, :shipping
20
+
21
+ #--- state machine
22
+ aasm :column => "status" do
23
+ state :pending, :enter => :enter_pending, :exit => :exit_pending, :initial => true
24
+ state :authorized, :enter => :enter_authorized, :exit => :exit_authorized
25
+ state :paid, :enter => :enter_paid, :exit => :exit_paid
26
+ state :voided, :enter => :enter_voided, :exit => :exit_voided
27
+ state :refunded, :enter => :enter_refunded, :exit => :exit_refunded
28
+ state :payment_declined, :enter => :enter_payment_declined, :exit => :exit_payment_declined
29
+
30
+ event :payment_paid do
31
+ transitions :from => :pending, :to => :paid, :guard => :guard_payment_paid_from_pending
32
+ end
33
+
34
+ event :payment_authorized do
35
+ transitions :from => :pending, :to => :authorized, :guard => :guard_payment_authorized_from_pending
36
+ transitions :from => :payment_declined, :to => :authorized, :guard => :guard_payment_authorized_from_payment_declined
37
+ end
38
+
39
+ event :payment_captured do
40
+ transitions :from => :authorized, :to => :paid, :guard => :guard_payment_captured_from_authorized
41
+ end
42
+
43
+ event :payment_voided do
44
+ transitions :from => :authorized, :to => :voided, :guard => :guard_payment_voided_from_authorized
45
+ end
46
+
47
+ event :payment_refunded do
48
+ transitions :from => :paid, :to => :refunded, :guard => :guard_payment_refunded_from_paid
49
+ end
50
+
51
+ event :transaction_declined do
52
+ transitions :from => :pending, :to => :payment_declined, :guard => :guard_transaction_declined_from_pending
53
+ transitions :from => :payment_declined, :to => :payment_declined, :guard => :guard_transaction_declined_from_payment_declined
54
+ transitions :from => :authorized, :to => :authorized, :guard => :guard_transaction_declined_from_authorized
55
+ end
56
+ end
57
+
58
+ # state transition callbacks
59
+ def enter_pending; end
60
+ def enter_authorized; end
61
+ def enter_paid; end
62
+ def enter_voided; end
63
+ def enter_refunded; end
64
+ def enter_payment_declined; end
65
+
66
+ def exit_pending; end
67
+ def exit_authorized; end
68
+ def exit_paid; end
69
+ def exit_voided; end
70
+ def exit_refunded; end
71
+ def exit_payment_declined; end
72
+
73
+ # event guard callbacks
74
+ def guard_transaction_declined_from_authorized; true; end
75
+ def guard_transaction_declined_from_payment_declined; true; end
76
+ def guard_transaction_declined_from_pending; true; end
77
+ def guard_payment_refunded_from_paid; true; end
78
+ def guard_payment_voided_from_authorized; true; end
79
+ def guard_payment_captured_from_authorized; true; end
80
+ def guard_payment_authorized_from_payment_declined; true; end
81
+ def guard_payment_authorized_from_pending; true; end
82
+ def guard_payment_paid_from_pending; true; end
83
+
84
+ #--- scopes
85
+ scope :paid, :conditions => {:status => "paid"}
86
+
87
+ #--- callbacks
88
+ before_save :number
89
+
90
+ #--- instance methods
91
+ alias_method :current_state, :aasm_current_state
92
+
93
+ def number
94
+ self[:number] ||= Order.generate_unique_id
95
+ end
96
+
97
+ # returns a hash of additional merchant data passed to authorize
98
+ # you want to pass in the following additional options
99
+ #
100
+ # :ip => ip address of the buyer
101
+ #
102
+ def payment_options(options={})
103
+ {}.merge(options)
104
+ end
105
+
106
+ # From payments, returns :credit_card, etc.
107
+ def payment_type
108
+ payments.first.payment_type if payments
109
+ end
110
+ alias_method :payment_method, :payment_type
111
+
112
+ # Human readable payment type
113
+ def payment_type_display
114
+ self.payment_type.to_s.titleize
115
+ end
116
+ alias_method :payment_method_display, :payment_type_display
117
+
118
+ # Net total amount
119
+ def net_total
120
+ self.net_amount ||= line_items.inject(::Money.new(0, self.currency || ::Money.default_currency.iso_code)) {|sum,line| sum + line.net_amount}
121
+ end
122
+
123
+ # Calculates tax and sets the tax_amount attribute
124
+ # It adds tax_amount across all line_items
125
+ def tax_total
126
+ self.tax_amount = line_items.inject(::Money.new(0, self.currency || ::Money.default_currency.iso_code)) {|sum,line| sum + line.tax_amount}
127
+ self.tax_amount
128
+ end
129
+
130
+ # Gross amount including tax
131
+ def gross_total
132
+ self.gross_amount ||= self.net_total + self.tax_total
133
+ end
134
+
135
+ # Same as gross_total
136
+ def total
137
+ self.gross_total
138
+ end
139
+
140
+ # updates the order and all contained line_items after an address has changed
141
+ # or an order item was added or removed. The order can only be evaluated if the
142
+ # created state is active. The order is saved if it is an existing order.
143
+ # Returns true if evaluation happend, false if not.
144
+ def evaluate
145
+ result = false
146
+ self.line_items.each(&:evaluate)
147
+ self.calculate
148
+ result = save(false) unless self.new_record?
149
+ result
150
+ end
151
+
152
+ protected
153
+
154
+ # override in subclass
155
+ def purchase_invoice?
156
+ false
157
+ end
158
+
159
+ # marks sales invoice, override in subclass
160
+ def sales_invoice?
161
+ false
162
+ end
163
+
164
+ def push_payment(a_payment)
165
+ a_payment.payable = self
166
+ self.payments.push(a_payment)
167
+ end
168
+
169
+ # Recalculates the order, adding order lines, tax and gross totals
170
+ def calculate
171
+ =begin
172
+ self.net_amount = nil
173
+ self.tax_amount = nil
174
+ self.gross_amount = nil
175
+ =end
176
+ self.total
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,128 @@
1
+ # Line Items used in orders and invoices
2
+ #
3
+ # LineItem::tax_rate_class_name = 'TaxRate'
4
+ #
5
+ module MerchantSidekick
6
+ class LineItem < ActiveRecord::Base
7
+ self.table_name = "line_items"
8
+
9
+ #--- accessors
10
+ cattr_accessor :tax_rate_class_name
11
+
12
+ #--- associations
13
+ belongs_to :order, :class_name => "::MerchantSidekick::Order"
14
+ belongs_to :invoice, :class_name => "::MerchantSidekick::Invoice"
15
+ belongs_to :sellable, :polymorphic => true
16
+
17
+ #--- mixins
18
+ money :net_amount, :cents => :net_cents, :currency => "currency"
19
+ money :tax_amount, :cents => :tax_cents, :currency => "currency"
20
+ money :gross_amount, :cents => :gross_cents, :currency => "currency"
21
+
22
+ #--- callbacks
23
+ before_save :save_sellable
24
+
25
+ #--- instance methods
26
+
27
+ # set #amount when adding sellable. This method is aliased to <tt>sellable=</tt>.
28
+ def sellable_with_price=(a_sellable)
29
+ calculate(a_sellable)
30
+ self.sellable_without_price = a_sellable
31
+ end
32
+ alias_method_chain :sellable=, :price
33
+
34
+ # There used to be a money :amount declration. When we added
35
+ # taxable line items, we assume that amount will refer to the
36
+ # net_amount (net_cents)
37
+ def amount
38
+ self.net_amount
39
+ end
40
+ alias_method :net_total, :amount
41
+
42
+ # Amount amounts to the net, for compatibility reasons
43
+ def amount=(net_money_amount)
44
+ self.net_amount = net_money_amount
45
+ end
46
+ alias_method :net_total=, :amount=
47
+
48
+ # shorter for gross_amount
49
+ def total
50
+ self.gross_amount
51
+ end
52
+ alias_method :gross_total, :total
53
+
54
+ # short for tax_amount
55
+ def tax
56
+ self.tax_amount
57
+ end
58
+ alias_method :tax_total, :tax
59
+
60
+ # calculates the amounts, like after an address change in the order and tries to save
61
+ # the line_item unless locked
62
+ # TODO find a better way to determine if the line item can still be updated
63
+ def evaluate
64
+ calculate(self.sellable)
65
+ save(false) unless new_record?
66
+ end
67
+
68
+ protected
69
+
70
+ def calculate(a_sellable)
71
+ if a_sellable && a_sellable.price
72
+ tax_rate_class = tax_rate_class_name.camelize.constantize rescue nil
73
+
74
+ # calculate tax amounts
75
+ #
76
+ # If we want to provide tax rates based on the order's billing address location,
77
+ # we require a class method,
78
+ #
79
+ # e.g.
80
+ #
81
+ # Tax.find_tax_rate({:origin => {...}, :destination => {...}})
82
+ # # where each hash provides :country_code => 'DE', :state_code => 'BY'
83
+
84
+ if tax_rate_class && a_sellable.respond_to?(:taxable?) && a_sellable.send(:taxable?)
85
+ # find tax rate for billing address, country/province
86
+ self.tax_rate = tax_rate_class.find_tax_rate(
87
+ :origin => order && order.origin_address ? order.origin_address.content_attributes : {},
88
+ :destination => order && order.shipping_address ? order.shipping_address.content_attributes : {},
89
+ :sellable => a_sellable
90
+ )
91
+
92
+ if a_sellable.respond_to?( :price_is_net? ) && a_sellable.send( :price_is_net? )
93
+ # gross = net + tax
94
+ self.net_amount = a_sellable.price
95
+ this_cents, this_currency = self.net_amount.cents, self.net_amount.currency
96
+ this_tax_cents = ( Float( this_cents * self.tax_rate / 10) / 10 ).round
97
+ self.tax_amount = ::Money.new(this_tax_cents, this_currency)
98
+ self.gross_amount = self.net_amount + self.tax_amount
99
+ else # price is gross
100
+ # net = gross - tax
101
+ this_gross_cents, this_currency = a_sellable.price.cents, a_sellable.price.currency
102
+ this_net_cents = (this_gross_cents * Float(100) / Float(100 + self.tax_rate)).round
103
+ self.net_amount = ::Money.new(this_net_cents, this_currency)
104
+ self.gross_amount = a_sellable.price
105
+ self.tax_amount = self.gross_amount - self.net_amount
106
+ end
107
+ else
108
+ # net = gross, tax = 0
109
+ self.net_amount = a_sellable.price
110
+ self.gross_amount = a_sellable.price
111
+ self.tax_amount = ::Money.new(0, a_sellable.currency)
112
+ self.tax_rate = 0
113
+ end
114
+ else
115
+ self.net_cents = 0
116
+ self.gross_cents = 0
117
+ self.tax_cents = 0
118
+ self.tax_rate = 0
119
+ self.currency = "USD"
120
+ end
121
+ end
122
+
123
+ def save_sellable
124
+ sellable.save if sellable && sellable.new_record?
125
+ end
126
+
127
+ end
128
+ end