spree_affirm 0.2.3

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 (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