solidus-adyen 0.1.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 (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>