solidus-adyen 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +26 -0
  3. data/.rubocop.yml +9 -0
  4. data/.rubocop_todo.yml +325 -0
  5. data/Gemfile +9 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +238 -0
  8. data/Rakefile +16 -0
  9. data/app/assets/javascripts/spree/backend/solidus-adyen.js +1 -0
  10. data/app/assets/javascripts/spree/checkout/payment/adyen.js +10 -0
  11. data/app/assets/javascripts/spree/frontend/solidus-adyen.js +1 -0
  12. data/app/assets/stylesheets/spree/backend/solidus-adyen/buttons.scss +15 -0
  13. data/app/assets/stylesheets/spree/backend/solidus-adyen/communication.scss +121 -0
  14. data/app/assets/stylesheets/spree/backend/solidus-adyen/variables.scss +4 -0
  15. data/app/assets/stylesheets/spree/backend/solidus-adyen.css +5 -0
  16. data/app/assets/stylesheets/spree/frontend/solidus-adyen.css +3 -0
  17. data/app/controllers/concerns/spree/adyen/admin/refunds_controller.rb +59 -0
  18. data/app/controllers/spree/adyen/hpps_controller.rb +22 -0
  19. data/app/controllers/spree/adyen_notifications_controller.rb +34 -0
  20. data/app/controllers/spree/adyen_redirect_controller.rb +90 -0
  21. data/app/models/adyen_notification.rb +159 -0
  22. data/app/models/concerns/spree/adyen/order.rb +11 -0
  23. data/app/models/concerns/spree/adyen/payment.rb +95 -0
  24. data/app/models/spree/adyen/hpp_source.rb +101 -0
  25. data/app/models/spree/adyen/notification_processor.rb +102 -0
  26. data/app/models/spree/adyen/presenters/communication.rb +31 -0
  27. data/app/models/spree/adyen/presenters/communications/adyen_notification.rb +26 -0
  28. data/app/models/spree/adyen/presenters/communications/base.rb +50 -0
  29. data/app/models/spree/adyen/presenters/communications/hpp_source.rb +31 -0
  30. data/app/models/spree/adyen/presenters/communications/log_entry.rb +28 -0
  31. data/app/models/spree/adyen/presenters/communications.rb +9 -0
  32. data/app/models/spree/gateway/adyen_hpp.rb +95 -0
  33. data/app/overrides/spree/admin/shared/_order_summary.rb +6 -0
  34. data/app/views/spree/admin/payments/source_forms/_adyen.html.erb +1 -0
  35. data/app/views/spree/admin/payments/source_views/_adyen.html.erb +14 -0
  36. data/app/views/spree/adyen/_manual_refund.html.erb +9 -0
  37. data/app/views/spree/adyen/communication/_communication.html.erb +42 -0
  38. data/app/views/spree/adyen/hpps/directory.html.erb +10 -0
  39. data/app/views/spree/checkout/payment/_adyen.html.erb +33 -0
  40. data/bin/checkout.rb +111 -0
  41. data/bin/regen.sh +1 -0
  42. data/config/initializers/solidus_adyen.rb +1 -0
  43. data/config/locales/en.yml +23 -0
  44. data/config/routes.rb +13 -0
  45. data/db/migrate/20131017040945_create_adyen_notifications.rb +29 -0
  46. data/db/migrate/20150911201942_add_index_adyen_notifications_psp_reference.rb +5 -0
  47. data/db/migrate/20150914162539_create_spree_adyen_hpp_sources.rb +21 -0
  48. data/db/migrate/20151007090519_add_days_to_ship_to_config.rb +5 -0
  49. data/db/migrate/20151020230830_remove_indices_on_adyen_notifications.rb +6 -0
  50. data/db/migrate/20151106093023_allow_merchant_reference_to_be_null_for_adyen_notification.rb +5 -0
  51. data/lib/solidus-adyen.rb +1 -0
  52. data/lib/spree/adyen/engine.rb +32 -0
  53. data/lib/spree/adyen/form.rb +135 -0
  54. data/lib/spree/adyen/hpp_check.rb +11 -0
  55. data/lib/spree/adyen/url.rb +30 -0
  56. data/lib/spree/adyen/version.rb +5 -0
  57. data/lib/spree/adyen.rb +6 -0
  58. data/solidus-adyen.gemspec +51 -0
  59. data/spec/controllers/concerns/spree/adyen/admin/refunds_controller_spec.rb +76 -0
  60. data/spec/controllers/spree/adyen/hpps_controller_spec.rb +43 -0
  61. data/spec/controllers/spree/adyen_notifications_controller_spec.rb +118 -0
  62. data/spec/controllers/spree/adyen_redirect_controller_spec.rb +154 -0
  63. data/spec/factories/active_merchant_billing_response.rb +23 -0
  64. data/spec/factories/adyen_notification.rb +91 -0
  65. data/spec/factories/spree_adyen_hpp_source.rb +15 -0
  66. data/spec/factories/spree_gateway_adyen_hpp.rb +23 -0
  67. data/spec/factories/spree_payment.rb +13 -0
  68. data/spec/lib/spree/adyen/form_spec.rb +214 -0
  69. data/spec/models/adyen_notification_spec.rb +86 -0
  70. data/spec/models/concerns/spree/adyen/order_spec.rb +22 -0
  71. data/spec/models/concerns/spree/adyen/payment_spec.rb +93 -0
  72. data/spec/models/spree/adyen/hpp_source_spec.rb +101 -0
  73. data/spec/models/spree/adyen/notification_processor_spec.rb +205 -0
  74. data/spec/models/spree/adyen/presenters/communication_spec.rb +62 -0
  75. data/spec/models/spree/gateway/adyen_hpp_spec.rb +76 -0
  76. data/spec/spec_helper.rb +65 -0
  77. data/spec/support/shared_contexts/mock_adyen_api.rb +47 -0
  78. metadata +463 -0
@@ -0,0 +1,101 @@
1
+ # This models the response that is received after a user is redirected from the
2
+ # Adyen Hosted Payment Pages. It's used as the the source for the Spree::Payment
3
+ # and keeps track of the messages received from the notifications end point.
4
+ #
5
+ # Attributes defined are dervived from the docs:
6
+ # https://docs.adyen.com/display/TD/HPP+payment+response
7
+ #
8
+ # Information about when certain action are valid:
9
+ # https://docs.adyen.com/display/TD/HPP+modifications
10
+ module Spree::Adyen
11
+ class HppSource < ::ActiveRecord::Base
12
+ MANUALLY_REFUNDABLE = [
13
+ "directEbanking"
14
+ ].freeze
15
+
16
+ PENDING = "PENDING".freeze
17
+ AUTHORISED = "AUTHORISED".freeze
18
+ REFUSED = "REFUSED".freeze
19
+ CANCELLED = "CANCELLED".freeze
20
+
21
+ # support updates from capital-cased responses, which is what adyen gives
22
+ # us
23
+ alias_attribute :authResult, :auth_result
24
+ alias_attribute :pspReference, :psp_reference
25
+ alias_attribute :merchantReference, :merchant_reference
26
+ alias_attribute :skinCode, :skin_code
27
+ alias_attribute :merchantSig, :merchant_sig
28
+ alias_attribute :paymentMethod, :payment_method
29
+ alias_attribute :shopperLocale, :shopper_locale
30
+ alias_attribute :merchantReturnData, :merchant_return_data
31
+
32
+ belongs_to :order, class_name: "Spree::Order",
33
+ primary_key: :number,
34
+ foreign_key: :merchant_reference
35
+
36
+ has_one :payment, class_name: "Spree::Payment", as: :source
37
+
38
+ # FIXME should change this to find the auth notification by order number,
39
+ # then all notification that have a original ref that matches it's psp
40
+ has_many :notifications,
41
+ class_name: "AdyenNotification",
42
+ foreign_key: :merchant_reference,
43
+ primary_key: :merchant_reference
44
+
45
+ def can_capture? payment
46
+ payment.uncaptured_amount != 0.0
47
+ end
48
+
49
+ def actions
50
+ if mutable?
51
+ authorised_actions
52
+ else
53
+ []
54
+ end
55
+ end
56
+
57
+ def can_cancel? payment
58
+ payment.refunds.empty?
59
+ end
60
+
61
+ def requires_manual_refund?
62
+ MANUALLY_REFUNDABLE.include?(payment_method)
63
+ end
64
+
65
+ def authorised?
66
+ # Many banks return pending, this is considered a valid response and
67
+ # the order should proceed.
68
+ [PENDING, AUTHORISED].include? auth_result
69
+ end
70
+
71
+ private
72
+ def mutable?
73
+ !payment.void? && !payment.processing?
74
+ end
75
+
76
+ # authorised_actions :: [String] | []
77
+ def authorised_actions
78
+ if auth_notification
79
+ auth_notification.
80
+ actions.map(&method(:transform_action))
81
+
82
+ else
83
+ []
84
+
85
+ end
86
+ end
87
+
88
+ def transform_action action
89
+ if action == "refund"
90
+ # return credit so that we go to the new refund action
91
+ "credit"
92
+ else
93
+ action
94
+ end
95
+ end
96
+
97
+ def auth_notification
98
+ notifications.processed.authorisation.last
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,102 @@
1
+ module Spree
2
+ module Adyen
3
+ class NotificationProcessor
4
+ attr_accessor :notification, :payment
5
+
6
+ def initialize(notification, payment = nil)
7
+ self.notification = notification
8
+ self.payment = payment ? payment : notification.payment
9
+ end
10
+
11
+ # for the given payment, process all notifications that are currently
12
+ # unprocessed in the order that they were dispatched.
13
+ def self.process_outstanding!(payment)
14
+ Spree::Payment.transaction do
15
+ payment.
16
+ source.
17
+ notifications(true). # bypass caching
18
+ unprocessed.
19
+ as_dispatched.
20
+ map do |notification|
21
+ new(notification, payment).process!
22
+ end
23
+ end
24
+ end
25
+
26
+ # only process the notification if there is a matching payment there's a
27
+ # number of reasons why there may not be a matching payment such as test
28
+ # notifications, reports etc, we just log them and then accept
29
+ def process!
30
+ Spree::Payment.transaction do
31
+ if payment
32
+ if !notification.success?
33
+ handle_failure
34
+
35
+ elsif notification.modification_event?
36
+ handle_modification_event
37
+
38
+ elsif notification.normal_event?
39
+ handle_normal_event
40
+
41
+ end
42
+ end
43
+ end
44
+
45
+ return notification
46
+ end
47
+
48
+ private
49
+
50
+ def handle_failure
51
+ notification.processed!
52
+ # ignore failures if the payment was already completed
53
+ return if payment.completed?
54
+ # might have to do something else on modification events,
55
+ # namely refunds
56
+ payment.failure!
57
+ end
58
+
59
+ def handle_modification_event
60
+ if notification.capture?
61
+ notification.processed!
62
+ complete_payment!
63
+
64
+ elsif notification.cancel_or_refund?
65
+ notification.processed!
66
+ payment.void
67
+
68
+ elsif notification.refund?
69
+ payment.refunds.create!(
70
+ amount: notification.value / 100, # cents to dollars
71
+ transaction_id: notification.psp_reference,
72
+ refund_reason_id: ::Spree::RefundReason.first.id # FIXME
73
+ )
74
+ # payment was processing, move back to completed
75
+ payment.complete!
76
+ notification.processed!
77
+ end
78
+ end
79
+
80
+ # normal event is defined as just AUTHORISATION
81
+ def handle_normal_event
82
+ if notification.auto_captured?
83
+ complete_payment!
84
+
85
+ else
86
+ payment.capture!
87
+
88
+ end
89
+ notification.processed!
90
+ end
91
+
92
+ def complete_payment!
93
+ money = ::Money.new(notification.value, notification.currency)
94
+
95
+ # this is copied from Spree::Payment::Processing#capture
96
+ payment.capture_events.create!(amount: money.to_f)
97
+ payment.update!(amount: payment.captured_amount)
98
+ payment.complete!
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,31 @@
1
+ module Spree
2
+ module Adyen
3
+ module Presenters
4
+ # Factory for creating communication presenters, based on a payment
5
+ # source.
6
+ class Communication < SimpleDelegator
7
+ PRESENTERS = [
8
+ ::Spree::Adyen::Presenters::Communications::AdyenNotification,
9
+ ::Spree::Adyen::Presenters::Communications::HppSource,
10
+ ::Spree::Adyen::Presenters::Communications::LogEntry
11
+ ].freeze
12
+
13
+ def self.from_source source
14
+ ([source] + source.notifications + source.payment.log_entries).
15
+ sort_by(&:created_at).
16
+ map { |x| build x }
17
+ end
18
+
19
+ def self.build object
20
+ presenter_for(object).new(object)
21
+ end
22
+
23
+ def self.presenter_for object
24
+ PRESENTERS.detect do |klass|
25
+ klass.applicable? object
26
+ end || fail("Couldn't map to a communication type")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,26 @@
1
+ module Spree
2
+ module Adyen
3
+ module Presenters
4
+ module Communications
5
+ class AdyenNotification <
6
+ ::Spree::Adyen::Presenters::Communications::Base
7
+
8
+ def fields
9
+ { event_code: event_code,
10
+ reason: reason,
11
+ amount: money.format
12
+ }
13
+ end
14
+
15
+ def inbound?
16
+ true
17
+ end
18
+
19
+ def self.applicable? obj
20
+ obj.is_a? ::AdyenNotification
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,50 @@
1
+ module Spree
2
+ module Adyen
3
+ module Presenters
4
+ module Communications
5
+ # Base presenters for generic server-api communication representation
6
+ #
7
+ # All communication presenters are expected to implement
8
+ # fields
9
+ # success?
10
+ # processed?
11
+ # inbound?
12
+ class Base < SimpleDelegator
13
+ # force to_partial_path to be called on the delegator and not the
14
+ # delgatee
15
+ def to_model
16
+ self
17
+ end
18
+
19
+ def to_partial_path
20
+ "spree/adyen/communication/communication"
21
+ end
22
+
23
+ def created_at_s
24
+ created_at.strftime "%d %b %H:%M:%S.%4N"
25
+ end
26
+
27
+ def present_fields
28
+ fields.compact
29
+ end
30
+
31
+ def css_class
32
+ [
33
+ success? ? "success" : "failure",
34
+ processed? ? "processed" : "unprocessed",
35
+ inbound? ? "received" : "sent"
36
+ ].
37
+ map { |klass| css_prefix klass }.
38
+ join(" ")
39
+ end
40
+
41
+ private
42
+
43
+ def css_prefix klass
44
+ "adyen-comm-" + klass
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,31 @@
1
+ module Spree
2
+ module Adyen
3
+ module Presenters
4
+ module Communications
5
+ class HppSource < ::Spree::Adyen::Presenters::Communications::Base
6
+ def fields
7
+ { result: auth_result,
8
+ payment_method: payment_method
9
+ }
10
+ end
11
+
12
+ def success?
13
+ true
14
+ end
15
+
16
+ def processed?
17
+ true
18
+ end
19
+
20
+ def inbound?
21
+ true
22
+ end
23
+
24
+ def self.applicable? obj
25
+ obj.is_a? Spree::Adyen::HppSource
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ module Spree
2
+ module Adyen
3
+ module Presenters
4
+ module Communications
5
+ class LogEntry < ::Spree::Adyen::Presenters::Communications::Base
6
+ delegate :success?, :message, to: :parsed_details
7
+
8
+ def processed?
9
+ true
10
+ end
11
+
12
+ def inbound?
13
+ false
14
+ end
15
+
16
+ def fields
17
+ { message: message
18
+ }
19
+ end
20
+
21
+ def self.applicable? obj
22
+ obj.is_a? Spree::LogEntry
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,9 @@
1
+ module Spree
2
+ module Adyen
3
+ module Presenters
4
+ # Explicit declaration of module
5
+ module Communications
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,95 @@
1
+ module Spree
2
+ # Gateway for Adyen Hosted Payment Pages solution
3
+ class Gateway::AdyenHPP < Gateway
4
+ preference :skin_code, :string
5
+ preference :shared_secret, :string
6
+ preference :days_to_ship, :integer, default: 1
7
+ preference :api_username, :string
8
+ preference :api_password, :string
9
+ preference :merchant_account, :string
10
+
11
+ def merchant_account
12
+ ENV["ADYEN_MERCHANT_ACCOUNT"] || preferred_merchant_account
13
+ end
14
+
15
+ def provider_class
16
+ ::Adyen::API
17
+ end
18
+
19
+ def provider
20
+ ::Adyen.configuration.api_username =
21
+ (ENV["ADYEN_API_USERNAME"] || preferred_api_username)
22
+ ::Adyen.configuration.api_password =
23
+ (ENV["ADYEN_API_PASSWORD"] || preferred_api_password)
24
+ ::Adyen.configuration.default_api_params[:merchant_account] =
25
+ merchant_account
26
+
27
+ provider_class
28
+ end
29
+
30
+ def method_type
31
+ "adyen"
32
+ end
33
+
34
+ def shared_secret
35
+ ENV["ADYEN_SHARED_SECRET"] || preferred_shared_secret
36
+ end
37
+
38
+ def skin_code
39
+ ENV["ADYEN_SKIN_CODE"] || preferred_skin_code
40
+ end
41
+
42
+ def ship_before_date
43
+ preferred_days_to_ship.days.from_now
44
+ end
45
+
46
+ def authorize(amount, source, gateway_options)
47
+ # to get around the order checking for processed payments we create payments
48
+ # in the checkout state and allow the payment method to attempt to auth
49
+ # them here. We just return a dummy response here because the payment has
50
+ # already been authorized
51
+ ActiveMerchant::Billing::Response.new(true, "successful hpp payment")
52
+ end
53
+
54
+ def capture(amount, psp_reference, currency:, **_opts)
55
+ value = { currency: currency, value: amount }
56
+
57
+ handle_response(
58
+ provider.capture_payment(psp_reference, value),
59
+ psp_reference)
60
+ end
61
+
62
+ def cancel(psp_reference, _gateway_options = {})
63
+ handle_response(
64
+ provider.cancel_or_refund_payment(psp_reference),
65
+ psp_reference)
66
+ end
67
+
68
+ def credit(amount, psp_reference, currency:, **_opts)
69
+ amount = { currency: currency, value: amount }
70
+
71
+ handle_response(
72
+ provider.refund_payment(psp_reference, amount),
73
+ psp_reference)
74
+ end
75
+
76
+ private
77
+
78
+ def handle_response response, original_reference
79
+ ActiveMerchant::Billing::Response.new(
80
+ response.success?,
81
+ message(response),
82
+ {},
83
+ authorization: original_reference
84
+ )
85
+ end
86
+
87
+ def message response
88
+ if response.success?
89
+ JSON.pretty_generate(response.params)
90
+ else
91
+ response.fault_message
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,6 @@
1
+ Deface::Override.new(
2
+ virtual_path: "spree/admin/shared/_order_summary",
3
+ name: "manual-refund-button",
4
+ insert_before: "#order_tab_summary",
5
+ partial: "spree/adyen/manual_refund"
6
+ )
@@ -0,0 +1 @@
1
+ Cannot create Hpp payments on the backend.
@@ -0,0 +1,14 @@
1
+ <% content_for :page_actions do %>
2
+ <li>
3
+ <%=
4
+ link_to "View in Customer Area",
5
+ Spree::Adyen::URL.payment_adyen_customer_area_url(
6
+ merchant_account: payment.payment_method.preferred_merchant_account,
7
+ psp_reference: payment.response_code
8
+ ),
9
+ class: "button fa"
10
+ %>
11
+ </li>
12
+ <% end %>
13
+
14
+ <%= render Spree::Adyen::Presenters::Communication.from_source(payment.source) %>
@@ -0,0 +1,9 @@
1
+ <% if @order.requires_manual_refund? %>
2
+ <%=
3
+ link_to(
4
+ t("solidus-adyen.manual_refund.order_button"),
5
+ Spree::Adyen::URL.modify_search_url(query: @order.number),
6
+ class: "fa fa-warning button button-error",
7
+ target: "_blank"
8
+ )%>
9
+ <% end %>
@@ -0,0 +1,42 @@
1
+ <%= content_tag :div, class: "#{communication.css_class} clearfix" do %>
2
+ <div class="adyen-comm">
3
+ <div class="adyen-comm-body">
4
+ <div class="adyen-comm-icon">
5
+ <% if !communication.processed? %>
6
+ <i
7
+ class="fa fa-warning adyen-comm-icon-unprocessed"
8
+ title="<%= t("solidus-adyen.payment.unprocessed")%>"
9
+ ></i>
10
+ <% end %>
11
+ <% if communication.success? %>
12
+ <i
13
+ class="fa fa-check adyen-comm-icon-success"
14
+ title="<%= t("solidus-adyen.payment.success")%>"
15
+ ></i>
16
+ <% else %>
17
+ <i
18
+ class="fa fa-times adyen-comm-icon-failure"
19
+ title="<%= t("solidus-adyen.payment.failure")%>"
20
+ ></i>
21
+ <% end %>
22
+ </div>
23
+ <div>
24
+ <div class="adyen-comm-content">
25
+ <% communication.present_fields.each do |name, val| %>
26
+ <div class="adyen-comm-content-row">
27
+ <div class="adyen-comm-content-cell">
28
+ <%= t(".#{name}") %>
29
+ </div>
30
+ <div class="adyen-comm-content-cell">
31
+ <%= val %>
32
+ </div>
33
+ </div>
34
+ <% end %>
35
+ </div>
36
+ <p class="adyen-comm-timestamp">
37
+ <%= communication.created_at_s %>
38
+ </p>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ <% end %>
@@ -0,0 +1,10 @@
1
+ <dl>
2
+ <% @brands.each do |brand| %>
3
+ <dt>
4
+ <%= brand[:brand_code] %>
5
+ </dt>
6
+ <dd>
7
+ <%= link_to brand[:name], brand[:payment_url] %>
8
+ </dd>
9
+ <% end %>
10
+ </dl>
@@ -0,0 +1,33 @@
1
+ <% content_for :head do %>
2
+ <%= javascript_include_tag 'spree/checkout/payment/adyen.js' %>
3
+ <% end %>
4
+
5
+ <p>
6
+ You'll be redirected to Adyen Hosted Payment Pages. After completing the
7
+ payment form you'll be back to the store.
8
+ </p>
9
+
10
+ <%= content_tag :div, "", id: 'adyen-hpp-details', data: {
11
+ url: directory_adyen_hpp_path(
12
+ order_id: @order.id,
13
+ payment_method_id: payment_method.id) } %>
14
+
15
+ <script type="text/javascript">
16
+ $(function() {
17
+ var spreePaymentRadio = $("#checkout_form_payment input:radio")
18
+
19
+ var hideCheckoutButtonForAdyen = function() {
20
+ if ($("#checkout_form_payment input:radio:checked").val() == <%= payment_method.id %>) {
21
+ $("#checkout_form_payment .form-buttons").hide();
22
+ } else {
23
+ $("#checkout_form_payment .form-buttons").show();
24
+ }
25
+ }
26
+
27
+ spreePaymentRadio.change(function() {
28
+ hideCheckoutButtonForAdyen();
29
+ });
30
+
31
+ hideCheckoutButtonForAdyen();
32
+ });
33
+ </script>