spree_affirm 0.2.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/Gemfile +19 -0
  4. data/LICENSE +29 -0
  5. data/README.md +51 -0
  6. data/Rakefile +15 -0
  7. data/Versionfile +5 -0
  8. data/app/assets/javascripts/spree/backend/spree_affirm.js +3 -0
  9. data/app/assets/javascripts/spree/frontend/spree_affirm.js +2 -0
  10. data/app/assets/stylesheets/spree/backend/spree_affirm.css +4 -0
  11. data/app/assets/stylesheets/spree/frontend/spree_affirm.css +4 -0
  12. data/app/controllers/spree/affirm_controller.rb +68 -0
  13. data/app/models/spree/affirm_checkout.rb +38 -0
  14. data/app/models/spree/gateway/affirm.rb +51 -0
  15. data/app/views/spree/admin/log_entries/_affirm.html.erb +4 -0
  16. data/app/views/spree/admin/log_entries/index.html.erb +28 -0
  17. data/app/views/spree/admin/payments/source_forms/_affirm.html.erb +19 -0
  18. data/app/views/spree/admin/payments/source_views/_affirm.html.erb +12 -0
  19. data/app/views/spree/checkout/payment/_affirm.html.erb +195 -0
  20. data/bin/rails +7 -0
  21. data/config/locales/en.yml +29 -0
  22. data/config/routes.rb +5 -0
  23. data/db/migrate/20140514194315_create_affirm_checkout.rb +10 -0
  24. data/lib/active_merchant/billing/affirm.rb +161 -0
  25. data/lib/generators/spree_affirm/install/install_generator.rb +21 -0
  26. data/lib/spree_affirm/engine.rb +27 -0
  27. data/lib/spree_affirm/factories/affirm_checkout_factory.rb +211 -0
  28. data/lib/spree_affirm/factories/affirm_payment_method_factory.rb +8 -0
  29. data/lib/spree_affirm/factories/payment_factory.rb +10 -0
  30. data/lib/spree_affirm/factories.rb +5 -0
  31. data/lib/spree_affirm/version.rb +3 -0
  32. data/lib/spree_affirm.rb +2 -0
  33. data/spec/controllers/affirm_controller_spec.rb +138 -0
  34. data/spec/lib/active_merchant/billing/affirm_spec.rb +294 -0
  35. data/spec/models/affirm_address_validator_spec.rb +13 -0
  36. data/spec/models/spree_affirm_checkout_spec.rb +328 -0
  37. data/spec/models/spree_gateway_affirm_spec.rb +134 -0
  38. data/spec/spec_helper.rb +98 -0
  39. data/spree_affirm.gemspec +30 -0
  40. metadata +190 -0
@@ -0,0 +1,161 @@
1
+ module ActiveMerchant #:nodoc:
2
+ module Billing #:nodoc:
3
+ class Affirm < Gateway
4
+ self.supported_countries = %w(US)
5
+ self.default_currency = 'USD'
6
+ self.money_format = :cents
7
+
8
+ def initialize(options = {})
9
+ requires!(options, :api_key, :secret_key, :server)
10
+ @api_key = options[:api_key]
11
+ @secret_key = options[:secret_key]
12
+ super
13
+ end
14
+
15
+ def set_charge(charge_id)
16
+ @charge_id = charge_id
17
+ end
18
+
19
+ def authorize(money, affirm_source, options = {})
20
+ result = commit(:post, "", {"checkout_token"=>affirm_source.token}, options, true)
21
+ return result unless result.success?
22
+
23
+ if amount(money).to_i != result.params["amount"].to_i
24
+ return Response.new(false,
25
+ "Auth amount does not match charge amount",
26
+ result.params
27
+ )
28
+ elsif result.params["pending"].to_s != "true"
29
+ return Response.new(false,
30
+ "There was an error authorizing this Charge",
31
+ result.params
32
+ )
33
+ end
34
+ result
35
+ end
36
+
37
+ # To create a charge on a card or a token, call
38
+ #
39
+ # purchase(money, card_hash_or_token, { ... })
40
+ #
41
+ # To create a charge on a customer, call
42
+ #
43
+ # purchase(money, nil, { :customer => id, ... })
44
+ def purchase(money, affirm_source, options = {})
45
+ result = authorize(money, affirm_source, options)
46
+ return result unless result.success?
47
+ capture(money, @charge_id, options)
48
+ end
49
+
50
+ def capture(money, charge_source, options = {})
51
+ post = {:amount => amount(money)}
52
+ set_charge(charge_source)
53
+ result = commit(:post, "#{@charge_id}/capture", post, options)
54
+ return result unless result.success?
55
+
56
+ if amount(money).to_i != result.params["amount"].to_i
57
+ return Response.new(false,
58
+ "Capture amount does not match charge amount",
59
+ result.params
60
+ )
61
+ end
62
+ result
63
+ end
64
+
65
+ def void(charge_source, options = {})
66
+ set_charge(charge_source)
67
+ commit(:post, "#{@charge_id}/void", {}, options)
68
+ end
69
+
70
+ def refund(money, charge_source, options = {})
71
+ post = {:amount => amount(money)}
72
+ set_charge(charge_source)
73
+ commit(:post, "#{@charge_id}/refund", post, options)
74
+ end
75
+
76
+ def credit(money, charge_source, options = {})
77
+ set_charge(charge_source)
78
+ return Response.new(true ,
79
+ "Credited Zero amount",
80
+ {},
81
+ :authorization => @charge_id,
82
+ ) unless money > 0
83
+ refund(money, charge_source, options)
84
+ end
85
+
86
+ def root_url
87
+ "#{root_api_url}charges/"
88
+ end
89
+
90
+ def root_api_url
91
+ "https://#{@options[:server]}/api/v2/"
92
+ end
93
+
94
+ def headers
95
+ {
96
+ "Content-Type" => "application/json",
97
+ "Authorization" => "Basic " + Base64.encode64(@api_key.to_s + ":" + @secret_key.to_s).gsub(/\n/, '').strip,
98
+ "User-Agent" => "Affirm/v1 ActiveMerchantBindings",
99
+ }
100
+ end
101
+
102
+ def parse(body)
103
+ JSON.parse(body)
104
+ end
105
+
106
+ def post_data(params)
107
+ return nil unless params
108
+ params.to_json
109
+ end
110
+
111
+ def response_error(raw_response)
112
+ begin
113
+ parse(raw_response)
114
+ rescue JSON::ParserError
115
+ json_error(raw_response)
116
+ end
117
+ end
118
+
119
+ def json_error(raw_response)
120
+ msg = 'Invalid response. Please contact affirm if you continue to receive this message.'
121
+ msg += " (The raw response returned by the API was #{raw_response.inspect})"
122
+ {
123
+ "error" => {
124
+ "message" => msg
125
+ }
126
+ }
127
+ end
128
+
129
+ def get_checkout(checkout_token)
130
+ _url = root_api_url + "checkout/#{checkout_token}"
131
+ _raw_response = ssl_request :get, _url, nil, headers
132
+
133
+ parse(_raw_response)
134
+ end
135
+
136
+ def commit(method, url, parameters=nil, options = {}, ret_charge=false)
137
+ raw_response = response = nil
138
+ success = false
139
+ begin
140
+ raw_response = ssl_request(method, root_url + url, post_data(parameters), headers)
141
+ response = parse(raw_response)
142
+ success = !response.key?("status_code") && (!ret_charge || response.key?("id"))
143
+ rescue ResponseError => e
144
+ raw_response = e.response.body
145
+ response = response_error(raw_response)
146
+ rescue JSON::ParserError
147
+ response = json_error(raw_response)
148
+ end
149
+
150
+ if success && ret_charge
151
+ @charge_id = response["id"]
152
+ end
153
+ Response.new(success,
154
+ success ? "Transaction approved" : response["message"],
155
+ response,
156
+ :authorization => @charge_id,
157
+ )
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,21 @@
1
+ module SpreeAffirm
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+
5
+ class_option :auto_run_migrations, :type => :boolean, :default => false
6
+
7
+ def add_migrations
8
+ run 'bundle exec rake railties:install:migrations FROM=spree_affirm'
9
+ end
10
+
11
+ def run_migrations
12
+ run_migrations = options[:auto_run_migrations] || ['', 'y', 'Y'].include?(ask 'Would you like to run the migrations now? [Y/n]')
13
+ if run_migrations
14
+ run 'bundle exec rake db:migrate'
15
+ else
16
+ puts 'Skipping rake db:migrate, don\'t forget to run it!'
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ module SpreeAffirm
2
+ class Engine < Rails::Engine
3
+ require 'spree/core'
4
+ isolate_namespace Spree
5
+ engine_name 'spree_affirm'
6
+
7
+
8
+ # use rspec for tests
9
+ config.generators do |g|
10
+ g.test_framework :rspec
11
+ end
12
+
13
+ config.autoload_paths += %W(#{config.root}/lib)
14
+
15
+ def self.activate
16
+ Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c|
17
+ Rails.configuration.cache_classes ? require(c) : load(c)
18
+ end
19
+ end
20
+
21
+ config.to_prepare &method(:activate).to_proc
22
+
23
+ initializer "spree.spree_affirm.payment_methods", :after => "spree.register.payment_methods" do |app|
24
+ app.config.spree.payment_methods << Spree::Gateway::Affirm
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,211 @@
1
+ def BASE_CHECKOUT_DETAILS
2
+ {
3
+ "merchant"=> {
4
+ "public_api_key"=> "PPPPPPPPPPPPPPP",
5
+ "user_cancel_url"=> "http=>//google.com/cancel",
6
+ "user_confirmation_url"=> "http=>//google.com/confirm",
7
+ "name"=> "Test Merchant"
8
+ },
9
+ "tax_amount"=> 0,
10
+ "billing"=> {
11
+ "address"=> {
12
+ "city"=> "San Francisco",
13
+ "street1"=> "12345 Main",
14
+ "street2"=> "300",
15
+ "region1_code"=> "AL",
16
+ "postal_code"=> "55555",
17
+ "country_code"=> "USA",
18
+ "for_billing"=> true,
19
+ "validation_source"=> 3
20
+ },
21
+ "email"=> "test@affirm.com",
22
+ "name"=> {
23
+ "for_billing"=> true,
24
+ "last"=> "Doe",
25
+ "first"=> "John"
26
+ }
27
+ },
28
+ "items"=> {
29
+ "xxx-xx-xxx-x"=> {
30
+ "sku"=> "xxx-xx-xxx-x",
31
+ "item_url"=> "http=>//google.com/products/the-blue-hat",
32
+ "display_name"=> "The Blue Hat",
33
+ "unit_price"=> 85000,
34
+ "qty"=> 1,
35
+ "item_type"=> "physical",
36
+ "item_image_url"=> "http=>//google.com/products/6/large/the-blue-hat"
37
+ }
38
+ },
39
+ "shipping"=> {
40
+ "name"=> {
41
+ "last"=> "Doe",
42
+ "first"=> "John"
43
+ },
44
+ "address"=> {
45
+ "city"=> "San Francisco",
46
+ "street1"=> "12345 Main Street",
47
+ "street2"=> "300",
48
+ "region1_code"=> "AL",
49
+ "postal_code"=> "94110",
50
+ "country_code"=> "USA",
51
+ "validation_source"=> 3
52
+ }
53
+ },
54
+ "checkout_id"=> "S123123-456",
55
+ "currency"=> "USD",
56
+ "meta"=> {
57
+ "release"=> "true",
58
+ "user_timezone"=> "America/Los_Angeles",
59
+ "__affirm_tracking_uuid"=> "97570a41-cd07-4f52-8869-46c6d2588407"
60
+ },
61
+ "discount_code"=> "",
62
+ "misc_fee_amount"=> 0,
63
+ "shipping_type"=> "Free National UPS",
64
+ "config"=> {
65
+ "required_billing_fields"=> [
66
+ "name",
67
+ "address",
68
+ "email"
69
+ ]
70
+ },
71
+ "api_version"=> "v2",
72
+ "shipping_amount"=> 0
73
+ }
74
+ end
75
+
76
+ FactoryGirl.define do
77
+ factory :affirm_checkout, class: Spree::AffirmCheckout do
78
+ token "12345678910"
79
+ association(:payment_method, factory: :affirm_payment_method)
80
+ association(:order, factory: :order_with_line_items)
81
+
82
+
83
+ transient do
84
+ stub_details true
85
+ shipping_address_mismatch false
86
+ billing_address_mismatch false
87
+ alternate_billing_address_format false
88
+ billing_address_full_name false
89
+ billing_email_mismatch false
90
+ extra_line_item false
91
+ missing_line_item false
92
+ quantity_mismatch false
93
+ price_mismatch false
94
+ full_name_case_mismatch false
95
+ end
96
+
97
+ after(:build) do |checkout, evaluator|
98
+
99
+ _details = BASE_CHECKOUT_DETAILS()
100
+
101
+ # case mismatch
102
+ unless evaluator.full_name_case_mismatch
103
+ _details['billing']['name'] = {
104
+ "full" => checkout.order.bill_address.firstname.upcase + " " +
105
+ checkout.order.bill_address.lastname.upcase
106
+ }
107
+ end
108
+
109
+ # shipping address
110
+ unless evaluator.shipping_address_mismatch
111
+ _details['shipping'] = {
112
+ "name" => {
113
+ "first" => checkout.order.ship_address.firstname,
114
+ "last" => checkout.order.ship_address.lastname
115
+ },
116
+ "address"=> {
117
+ "city"=> checkout.order.ship_address.city,
118
+ "street1"=> checkout.order.ship_address.address1,
119
+ "street2"=> checkout.order.ship_address.address2,
120
+ "region1_code"=> checkout.order.ship_address.state.abbr,
121
+ "postal_code"=> checkout.order.ship_address.zipcode,
122
+ "country_code"=> checkout.order.ship_address.country.iso3
123
+ }
124
+ }
125
+ end
126
+
127
+ # billing address
128
+ unless evaluator.billing_address_mismatch
129
+ _details["billing"] = {
130
+ "email" => "joe@schmoe.com",
131
+ "name" => {
132
+ "first" => checkout.order.bill_address.firstname,
133
+ "last" => checkout.order.bill_address.lastname
134
+ },
135
+ "address"=> {
136
+ "city"=> checkout.order.bill_address.city,
137
+ "street1"=> checkout.order.bill_address.address1,
138
+ "street2"=> checkout.order.bill_address.address2,
139
+ "region1_code"=> checkout.order.bill_address.state.abbr,
140
+ "postal_code"=> checkout.order.bill_address.zipcode,
141
+ "country_code"=> checkout.order.bill_address.country.iso3
142
+ }
143
+ }
144
+ end
145
+
146
+ # use alternate format for billing address
147
+ if evaluator.alternate_billing_address_format
148
+ _details['billing']["address"] = {
149
+ "city" => _details['billing']["address"]["city"],
150
+ "line1"=> _details['billing']["address"]["street1"],
151
+ "line2"=> _details['billing']["address"]["street2"],
152
+ "state"=> _details['billing']["address"]["postal_code"],
153
+ "zipcode"=> _details['billing']["address"]["region1_code"],
154
+ "country"=> _details['billing']["address"]["country_code"]
155
+ }
156
+ end
157
+
158
+ # use name.full instead of first/last
159
+ if evaluator.billing_address_full_name
160
+ _details['billing']['name'] = {
161
+ 'full' => "#{_details['billing']['name']['first']} #{_details['billing']['name']['last']}"
162
+ }
163
+ end
164
+
165
+
166
+ # billing email
167
+ unless evaluator.billing_email_mismatch
168
+ _details["billing"]["email"] = checkout.order.email
169
+ end
170
+
171
+ # setup items in cart
172
+ _details['items'] = {}
173
+ checkout.order.line_items.each do |item|
174
+ _details['items'][item.variant.sku] = {
175
+ "qty" => item.quantity.to_s,
176
+ "unit_price" => (item.price*100).to_s,
177
+ "display_name" => item.product.name
178
+ }
179
+ end
180
+
181
+ if evaluator.extra_line_item
182
+ _details['items']['extra-1-2-3'] = {
183
+ "qty" => "1",
184
+ "unit_price" => "12300",
185
+ "display_name" => "Really cool hat"
186
+ }
187
+ end
188
+
189
+ if evaluator.missing_line_item
190
+ _details['items'].delete _details['items'].keys.last
191
+ end
192
+
193
+ if evaluator.quantity_mismatch
194
+ _last_item = _details['items'][_details['items'].keys.last]
195
+ _details['items'][_details['items'].keys.last]['qty'] = (_last_item['qty'].to_i + 1).to_s
196
+ end
197
+
198
+ if evaluator.price_mismatch
199
+ _last_item = _details['items'][_details['items'].keys.last]
200
+ _details['items'][_details['items'].keys.last]['unit_price'] = 456456
201
+ end
202
+
203
+ if evaluator.stub_details
204
+ checkout.stub(details: _details)
205
+ end
206
+ end
207
+
208
+ end
209
+ end
210
+
211
+
@@ -0,0 +1,8 @@
1
+ FactoryGirl.define do
2
+ factory :affirm_payment_method, class: Spree::Gateway::Affirm do
3
+ name "Staging Affirm Split Pay"
4
+ active true
5
+ environment "test"
6
+ auto_capture false
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ FactoryGirl.define do
2
+ factory :affirm_payment, class: Spree::Payment do
3
+ amount 45.75
4
+ association(:payment_method, factory: :affirm_payment_method)
5
+ association(:source, factory: :affirm_checkout)
6
+ order
7
+ state 'checkout'
8
+ response_code '12345'
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ require 'factory_girl'
2
+
3
+ Dir["#{File.dirname(__FILE__)}/factories/**"].each do |f|
4
+ require File.expand_path(f)
5
+ end
@@ -0,0 +1,3 @@
1
+ module SpreeAffirm
2
+ VERSION = "0.2.3"
3
+ end
@@ -0,0 +1,2 @@
1
+ require 'spree_core'
2
+ require 'spree_affirm/engine'
@@ -0,0 +1,138 @@
1
+ require 'spec_helper'
2
+
3
+ describe Spree::AffirmController do
4
+ let(:user) { FactoryGirl.create(:user) }
5
+ let(:checkout) { FactoryGirl.build(:affirm_checkout) }
6
+ let(:bad_billing_checkout) { FactoryGirl.build(:affirm_checkout, billing_address_mismatch: true) }
7
+ let(:bad_shipping_checkout) { FactoryGirl.build(:affirm_checkout, shipping_address_mismatch: true) }
8
+ let(:bad_email_checkout) { FactoryGirl.build(:affirm_checkout, billing_email_mismatch: true) }
9
+
10
+
11
+ describe "POST confirm" do
12
+
13
+ def post_request(token, payment_id)
14
+ post :confirm, checkout_token: token, payment_method_id: payment_id, use_route: 'spree'
15
+ end
16
+
17
+ before do
18
+ controller.stub authenticate_spree_user!: true
19
+ controller.stub spree_current_user: user
20
+ end
21
+
22
+ context "when the checkout matches the order" do
23
+ before do
24
+ Spree::AffirmCheckout.stub new: checkout
25
+ controller.stub current_order: checkout.order
26
+ end
27
+
28
+ context "when no checkout_token is provided" do
29
+ it "redirects to the current order state" do
30
+ post_request(nil, nil)
31
+ expect(response).to redirect_to(controller.checkout_state_path(checkout.order.state))
32
+ end
33
+ end
34
+
35
+ context "when the order is complete" do
36
+ before do
37
+ checkout.order.state = 'complete'
38
+ end
39
+ it "redirects to the current order state" do
40
+ post_request '123456789', checkout.payment_method.id
41
+ expect(response).to redirect_to(controller.order_path(checkout.order))
42
+ end
43
+ end
44
+
45
+ context "when the order state is payment" do
46
+ before do
47
+ checkout.order.state = 'payment'
48
+ end
49
+
50
+ it "creates a new payment" do
51
+ post_request "123423423", checkout.payment_method.id
52
+
53
+ expect(checkout.order.payments.first.source).to eq(checkout)
54
+ end
55
+
56
+ # it "transitions to complete if confirmation is not required" do
57
+ # checkout.order.stub confirmation_required?: false
58
+ # post_request "123423423", checkout.payment_method.id
59
+
60
+ # expect(checkout.order.state).to eq("complete")
61
+ # end
62
+
63
+ it "transitions to confirm if confirmation is required" do
64
+ checkout.order.stub confirmation_required?: true
65
+ post_request "123423423", checkout.payment_method.id
66
+
67
+ expect(checkout.order.reload.state).to eq("confirm")
68
+ end
69
+ end
70
+
71
+ end
72
+
73
+ context "when the billing_address does not match the order" do
74
+ before do
75
+ Spree::AffirmCheckout.stub new: bad_billing_checkout
76
+ state = FactoryGirl.create(:state, abbr: bad_billing_checkout.details['billing']['address']['region1_code'])
77
+ Spree::State.stub find_by_abbr: state, find_by_name: state
78
+ controller.stub current_order: bad_billing_checkout.order
79
+ end
80
+
81
+ it "creates a new address record for the order" do
82
+ _old_billing_address = bad_billing_checkout.order.bill_address
83
+ post_request '12345789', bad_billing_checkout.payment_method.id
84
+
85
+ expect(bad_billing_checkout.order.bill_address).not_to be(_old_billing_address)
86
+ expect(bad_billing_checkout.valid?).to be(true)
87
+ end
88
+ end
89
+
90
+
91
+ context "when the shipping_address does not match the order" do
92
+ before do
93
+ Spree::AffirmCheckout.stub new: bad_shipping_checkout
94
+ state = FactoryGirl.create(:state, abbr: bad_shipping_checkout.details['shipping']['address']['region1_code'])
95
+ Spree::State.stub find_by_abbr: state, find_by_name: state
96
+ controller.stub current_order: bad_shipping_checkout.order
97
+ end
98
+
99
+ it "creates a new address record for the order" do
100
+ _old_shipping_address = bad_shipping_checkout.order.ship_address
101
+ post_request '12345789', bad_shipping_checkout.payment_method.id
102
+
103
+ expect(bad_shipping_checkout.order.ship_address).not_to be(_old_shipping_address)
104
+ expect(bad_shipping_checkout.valid?).to be(true)
105
+ end
106
+ end
107
+
108
+
109
+
110
+ context "when the billing_email does not match the order" do
111
+ before do
112
+ Spree::AffirmCheckout.stub new: bad_email_checkout
113
+ controller.stub current_order: bad_email_checkout.order
114
+ end
115
+
116
+ it "updates the billing_email on the order" do
117
+ _old_email = bad_email_checkout.order.email
118
+ post_request '12345789', bad_email_checkout.payment_method.id
119
+
120
+ expect(bad_email_checkout.order.email).not_to be(_old_email)
121
+ expect(bad_email_checkout.valid?).to be(true)
122
+ end
123
+ end
124
+
125
+
126
+ context "there is no current order" do
127
+ before(:each) do
128
+ controller.stub current_order: nil
129
+ end
130
+
131
+ it "raises an ActiveRecord::RecordNotFound error" do
132
+ expect do
133
+ post_request nil, nil
134
+ end.to raise_error(ActiveRecord::RecordNotFound)
135
+ end
136
+ end
137
+ end
138
+ end