solidus_paypal_braintree 0.2.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (168) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +40 -0
  3. data/.gem_release.yml +5 -0
  4. data/.github/stale.yml +17 -0
  5. data/.gitignore +18 -0
  6. data/.rspec +2 -0
  7. data/.rubocop.yml +76 -0
  8. data/CHANGELOG.md +258 -0
  9. data/Gemfile +41 -0
  10. data/LICENSE +2 -2
  11. data/README.md +208 -48
  12. data/Rakefile +4 -28
  13. data/app/assets/config/solidus_paypal_braintree_manifest.js +1 -0
  14. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_active_blue_button_280x48.svg +19 -0
  15. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_active_blue_button_320x48.svg +19 -0
  16. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_active_blue_button_375x48.svg +19 -0
  17. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_active_white_button_280x48.svg +19 -0
  18. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_active_white_button_320x48.svg +19 -0
  19. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_active_white_button_375x48.svg +19 -0
  20. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_blue_acceptance_mark.svg +15 -0
  21. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_blue_button_280x48.svg +19 -0
  22. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_blue_button_320x48.svg +19 -0
  23. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_blue_button_375x48.svg +19 -0
  24. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_blue_logo.svg +18 -0
  25. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_white_acceptance_mark.svg +20 -0
  26. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_white_button_280x48.svg +19 -0
  27. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_white_button_320x48.svg +19 -0
  28. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_white_button_375x48.svg +19 -0
  29. data/app/assets/images/solidus_paypal_braintree/venmo/venmo_white_logo.svg +18 -0
  30. data/app/assets/javascripts/solidus_paypal_braintree/checkout.js +32 -3
  31. data/app/assets/javascripts/solidus_paypal_braintree/client.js +58 -5
  32. data/app/assets/javascripts/solidus_paypal_braintree/constants.js +36 -5
  33. data/app/assets/javascripts/solidus_paypal_braintree/frontend.js +2 -0
  34. data/app/assets/javascripts/solidus_paypal_braintree/hosted_form.js +15 -5
  35. data/app/assets/javascripts/solidus_paypal_braintree/paypal_button.js +90 -26
  36. data/app/assets/javascripts/solidus_paypal_braintree/paypal_messaging.js +22 -0
  37. data/app/assets/javascripts/solidus_paypal_braintree/venmo_button.js +86 -0
  38. data/app/assets/javascripts/spree/backend/solidus_paypal_braintree.js +2 -2
  39. data/app/assets/javascripts/spree/frontend/paypal_button.js +15 -13
  40. data/app/assets/stylesheets/spree/frontend/solidus_paypal_braintree.css +12 -0
  41. data/app/decorators/controllers/solidus_paypal_braintree/admin_payments_controller_decorator.rb +11 -0
  42. data/app/decorators/controllers/solidus_paypal_braintree/checkout_controller_decorator.rb +11 -0
  43. data/app/decorators/controllers/solidus_paypal_braintree/client_tokens_controller.rb +41 -0
  44. data/app/decorators/controllers/solidus_paypal_braintree/orders_controller_decorator.rb +11 -0
  45. data/app/decorators/models/solidus_paypal_braintree/spree/store_decorator.rb +20 -0
  46. data/app/decorators/models/solidus_paypal_braintree/spree/user_decorator.rb +13 -0
  47. data/app/helpers/solidus_paypal_braintree/braintree_admin_helper.rb +23 -0
  48. data/app/helpers/solidus_paypal_braintree/braintree_checkout_helper.rb +60 -0
  49. data/app/models/application_record.rb +2 -0
  50. data/app/models/solidus_paypal_braintree/address.rb +64 -0
  51. data/app/models/solidus_paypal_braintree/avs_result.rb +69 -0
  52. data/app/models/solidus_paypal_braintree/configuration.rb +39 -3
  53. data/app/models/solidus_paypal_braintree/customer.rb +7 -3
  54. data/app/models/solidus_paypal_braintree/gateway.rb +150 -43
  55. data/app/models/solidus_paypal_braintree/response.rb +49 -21
  56. data/app/models/solidus_paypal_braintree/source.rb +70 -10
  57. data/app/models/solidus_paypal_braintree/transaction.rb +3 -2
  58. data/app/models/solidus_paypal_braintree/transaction_address.rb +36 -15
  59. data/app/models/solidus_paypal_braintree/transaction_import.rb +18 -12
  60. data/app/overrides/spree/payments/payment/add_paypal_funding_source_to_payment.rb +9 -0
  61. data/app/views/spree/api/payments/source_views/_paypal_braintree.json.jbuilder +3 -0
  62. data/app/views/spree/checkout/existing_payment/_paypal_braintree.html.erb +10 -0
  63. data/app/views/spree/shared/_apple_pay_button.html.erb +27 -0
  64. data/app/views/spree/shared/_braintree_errors.html.erb +16 -0
  65. data/app/views/spree/shared/_braintree_hosted_fields.html.erb +25 -8
  66. data/app/views/spree/shared/_paypal_braintree_head_scripts.html.erb +26 -0
  67. data/app/views/spree/shared/_paypal_cart_button.html.erb +38 -0
  68. data/app/views/spree/shared/_paypal_messaging.html.erb +13 -0
  69. data/app/views/spree/shared/_venmo_button.html.erb +33 -0
  70. data/bin/console +17 -0
  71. data/bin/rails +15 -0
  72. data/bin/setup +8 -0
  73. data/config/locales/en.yml +66 -0
  74. data/config/locales/it.yml +56 -0
  75. data/config/routes.rb +2 -0
  76. data/db/migrate/20160906201711_create_solidus_paypal_braintree_customers.rb +3 -1
  77. data/db/migrate/20161125172005_add_braintree_configuration_to_stores.rb +5 -7
  78. data/db/migrate/20170505193712_add_null_constraint_to_sources.rb +3 -1
  79. data/db/migrate/20190705115327_add_paypal_button_preferences_to_braintree_configurations.rb +5 -0
  80. data/db/migrate/20190911141712_add_3d_secure_to_braintree_configuration.rb +5 -0
  81. data/db/migrate/20211222170950_add_paypal_funding_source_to_solidus_paypal_braintree_sources.rb +5 -0
  82. data/db/migrate/20220104150301_add_venmo_to_braintree_configuration.rb +5 -0
  83. data/lib/controllers/backend/solidus_paypal_braintree/configurations_controller.rb +23 -5
  84. data/lib/controllers/frontend/solidus_paypal_braintree/checkouts_controller.rb +25 -21
  85. data/lib/controllers/frontend/solidus_paypal_braintree/transactions_controller.rb +56 -50
  86. data/lib/generators/solidus_paypal_braintree/install/install_generator.rb +7 -6
  87. data/lib/solidus_paypal_braintree/country_mapper.rb +4 -2
  88. data/lib/solidus_paypal_braintree/engine.rb +34 -22
  89. data/lib/solidus_paypal_braintree/factories.rb +41 -6
  90. data/lib/solidus_paypal_braintree/request_protection.rb +21 -0
  91. data/lib/solidus_paypal_braintree/version.rb +3 -1
  92. data/lib/solidus_paypal_braintree.rb +5 -1
  93. data/lib/views/backend/solidus_paypal_braintree/configurations/list.html.erb +38 -5
  94. data/lib/views/backend/spree/admin/payments/source_forms/_paypal_braintree.html.erb +2 -2
  95. data/lib/views/backend/spree/admin/payments/source_views/_paypal_braintree.html.erb +7 -2
  96. data/lib/views/backend/spree/admin/shared/preference_fields/_preference_select.html.erb +13 -0
  97. data/lib/views/backend_v1.2/spree/admin/payments/source_forms/_paypal_braintree.html.erb +2 -2
  98. data/lib/views/backend_v2.4/spree/admin/shared/preference_fields/_hash.html.erb +12 -0
  99. data/lib/views/frontend/solidus_paypal_braintree/payments/_payment.html.erb +12 -0
  100. data/lib/views/frontend/spree/checkout/payment/_paypal_braintree.html.erb +10 -77
  101. data/lib/views/frontend/spree/shared/_paypal_checkout_button.html.erb +32 -0
  102. data/solidus_paypal_braintree.gemspec +43 -0
  103. data/spec/controllers/solidus_paypal_braintree/checkouts_controller_spec.rb +99 -0
  104. data/spec/controllers/solidus_paypal_braintree/client_tokens_controller_spec.rb +55 -0
  105. data/spec/controllers/solidus_paypal_braintree/configurations_controller_spec.rb +73 -0
  106. data/spec/controllers/solidus_paypal_braintree/transactions_controller_spec.rb +183 -0
  107. data/spec/features/backend/configuration_spec.rb +23 -0
  108. data/spec/features/backend/new_payment_spec.rb +137 -0
  109. data/spec/features/frontend/braintree_credit_card_checkout_spec.rb +191 -0
  110. data/spec/features/frontend/paypal_checkout_spec.rb +166 -0
  111. data/spec/features/frontend/venmo_checkout_spec.rb +189 -0
  112. data/spec/fixtures/cassettes/admin/invalid_credit_card.yml +63 -0
  113. data/spec/fixtures/cassettes/admin/resubmit_credit_card.yml +352 -0
  114. data/spec/fixtures/cassettes/admin/valid_credit_card.yml +412 -0
  115. data/spec/fixtures/cassettes/braintree/create_profile.yml +71 -0
  116. data/spec/fixtures/cassettes/braintree/generate_token.yml +63 -0
  117. data/spec/fixtures/cassettes/braintree/token.yml +63 -0
  118. data/spec/fixtures/cassettes/checkout/invalid_credit_card.yml +63 -0
  119. data/spec/fixtures/cassettes/checkout/resubmit_credit_card.yml +216 -0
  120. data/spec/fixtures/cassettes/checkout/update.yml +71 -0
  121. data/spec/fixtures/cassettes/checkout/valid_credit_card.yml +156 -0
  122. data/spec/fixtures/cassettes/checkout/valid_venmo_transaction.yml +599 -0
  123. data/spec/fixtures/cassettes/gateway/authorize/credit_card/address.yml +86 -0
  124. data/spec/fixtures/cassettes/gateway/authorize/merchant_account/EUR.yml +154 -0
  125. data/spec/fixtures/cassettes/gateway/authorize/paypal/EUR.yml +90 -0
  126. data/spec/fixtures/cassettes/gateway/authorize/paypal/address.yml +90 -0
  127. data/spec/fixtures/cassettes/gateway/authorize.yml +86 -0
  128. data/spec/fixtures/cassettes/gateway/authorized_transaction.yml +73 -0
  129. data/spec/fixtures/cassettes/gateway/cancel/missing.yml +63 -0
  130. data/spec/fixtures/cassettes/gateway/cancel/refunds.yml +272 -0
  131. data/spec/fixtures/cassettes/gateway/cancel/void.yml +201 -0
  132. data/spec/fixtures/cassettes/gateway/capture.yml +141 -0
  133. data/spec/fixtures/cassettes/gateway/complete.yml +157 -0
  134. data/spec/fixtures/cassettes/gateway/credit.yml +208 -0
  135. data/spec/fixtures/cassettes/gateway/purchase.yml +87 -0
  136. data/spec/fixtures/cassettes/gateway/settled_transaction.yml +140 -0
  137. data/spec/fixtures/cassettes/gateway/void.yml +137 -0
  138. data/spec/fixtures/cassettes/source/card_type.yml +267 -0
  139. data/spec/fixtures/cassettes/source/last4.yml +267 -0
  140. data/spec/fixtures/cassettes/transaction/import/valid/capture.yml +224 -0
  141. data/spec/fixtures/cassettes/transaction/import/valid.yml +71 -0
  142. data/spec/fixtures/views/spree/orders/edit.html.erb +50 -0
  143. data/spec/helpers/solidus_paypal_braintree/braintree_admin_helper_spec.rb +17 -0
  144. data/spec/helpers/solidus_paypal_braintree/braintree_checkout_helper_spec.rb +70 -0
  145. data/spec/models/solidus_paypal_braintree/address_spec.rb +71 -0
  146. data/spec/models/solidus_paypal_braintree/avs_result_spec.rb +317 -0
  147. data/spec/models/solidus_paypal_braintree/gateway_spec.rb +692 -0
  148. data/spec/models/solidus_paypal_braintree/response_spec.rb +280 -0
  149. data/spec/models/solidus_paypal_braintree/source_spec.rb +499 -0
  150. data/spec/models/solidus_paypal_braintree/transaction_address_spec.rb +235 -0
  151. data/spec/models/solidus_paypal_braintree/transaction_import_spec.rb +300 -0
  152. data/spec/models/solidus_paypal_braintree/transaction_spec.rb +85 -0
  153. data/spec/models/spree/store_spec.rb +14 -0
  154. data/spec/requests/spree/api/orders_controller_spec.rb +36 -0
  155. data/spec/spec_helper.rb +29 -0
  156. data/spec/support/capybara.rb +7 -0
  157. data/spec/support/factories.rb +2 -0
  158. data/spec/support/gateway_helpers.rb +29 -0
  159. data/spec/support/order_ready_for_payment.rb +37 -0
  160. data/spec/support/vcr.rb +42 -0
  161. data/spec/support/views.rb +1 -0
  162. metadata +226 -166
  163. data/app/controllers/solidus_paypal_braintree/client_tokens_controller.rb +0 -21
  164. data/app/helpers/braintree_admin_helper.rb +0 -18
  165. data/app/models/spree/store_decorator.rb +0 -11
  166. data/app/overrides/admin_navigation_menu.rb +0 -6
  167. data/config/initializers/braintree.rb +0 -1
  168. data/lib/views/backend/solidus_paypal_braintree/configurations/_admin_tab.html.erb +0 -3
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_merchant/billing/avs_result'
4
+
5
+ module SolidusPaypalBraintree
6
+ class AVSResult < ActiveMerchant::Billing::AVSResult
7
+ # Mapping took from ActiveMerchant::Billing::BraintreeBlueGateway
8
+ AVS_MAPPING = {
9
+ 'M' => {
10
+ 'M' => 'M',
11
+ 'N' => 'A',
12
+ 'U' => 'B',
13
+ 'I' => 'B',
14
+ 'A' => 'B'
15
+ },
16
+ 'N' => {
17
+ 'M' => 'Z',
18
+ 'N' => 'C',
19
+ 'U' => 'C',
20
+ 'I' => 'C',
21
+ 'A' => 'C'
22
+ },
23
+ 'U' => {
24
+ 'M' => 'P',
25
+ 'N' => 'N',
26
+ 'U' => 'I',
27
+ 'I' => 'I',
28
+ 'A' => 'I'
29
+ },
30
+ 'I' => {
31
+ 'M' => 'P',
32
+ 'N' => 'C',
33
+ 'U' => 'I',
34
+ 'I' => 'I',
35
+ 'A' => 'I'
36
+ },
37
+ 'A' => {
38
+ 'M' => 'P',
39
+ 'N' => 'C',
40
+ 'U' => 'I',
41
+ 'I' => 'I',
42
+ 'A' => 'I'
43
+ },
44
+ 'B' => {
45
+ 'B' => 'B'
46
+ },
47
+ nil => { nil => nil }
48
+ }.freeze
49
+
50
+ class << self
51
+ private :new
52
+
53
+ def build(transaction)
54
+ new(
55
+ code: avs_code_from(transaction),
56
+ street_match: transaction.avs_street_address_response_code,
57
+ postal_match: transaction.avs_postal_code_response_code
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ def avs_code_from(transaction)
64
+ transaction.avs_error_response_code ||
65
+ AVS_MAPPING[transaction.avs_street_address_response_code][transaction.avs_postal_code_response_code]
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,5 +1,41 @@
1
- class SolidusPaypalBraintree::Configuration < ApplicationRecord
2
- belongs_to :store, class_name: 'Spree::Store'
1
+ # frozen_string_literal: true
3
2
 
4
- validates :store, presence: true
3
+ module SolidusPaypalBraintree
4
+ class Configuration < ::Spree::Base
5
+ PAYPAL_BUTTON_PREFERENCES = {
6
+ color: { availables: %w[gold blue silver white black], default: 'white' },
7
+ shape: { availables: %w[pill rect], default: 'rect' },
8
+ label: { availables: %w[checkout credit pay buynow paypal installment], default: 'checkout' },
9
+ tagline: { availables: %w[true false], default: 'false' },
10
+ layout: { availables: %w[horizontal vertical], default: 'horizontal' },
11
+ messaging: { availables: %w[true false], default: 'false' }
12
+ }.freeze
13
+
14
+ unless respond_to?(:preference)
15
+ include ::Spree::Preferences::Persistable
16
+ end
17
+
18
+ belongs_to :store, class_name: 'Spree::Store', optional: false
19
+
20
+ # Preferences for Paypal button
21
+ PAYPAL_BUTTON_PREFERENCES.each do |name, desc|
22
+ preference_name = "paypal_button_#{name}".to_sym
23
+ attribute_name = "preferred_#{preference_name}".to_sym
24
+
25
+ preference preference_name, :string, default: desc[:default]
26
+
27
+ validates attribute_name, inclusion: desc[:availables]
28
+ end
29
+
30
+ preference :venmo_button_color, :preference_select, default: 'blue'
31
+ preference :venmo_button_width, :preference_select, default: '320'
32
+
33
+ def preferred_venmo_button_color_options
34
+ [["Blue", "blue"], ["White", "white"]]
35
+ end
36
+
37
+ def preferred_venmo_button_width_options
38
+ [["280", "280"], ["320", "320"], ["375", "375"]]
39
+ end
40
+ end
5
41
  end
@@ -1,4 +1,8 @@
1
- class SolidusPaypalBraintree::Customer < ApplicationRecord
2
- belongs_to :user, class_name: Spree::UserClassHandle.new
3
- has_many :sources, class_name: "SolidusPaypalBraintree::Source", inverse_of: :customer
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPaypalBraintree
4
+ class Customer < ApplicationRecord
5
+ belongs_to :user, class_name: ::Spree::UserClassHandle.new, optional: true
6
+ has_many :sources, class_name: "SolidusPaypalBraintree::Source", inverse_of: :customer, dependent: :destroy
7
+ end
4
8
  end
@@ -1,20 +1,30 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'braintree'
2
4
 
3
5
  module SolidusPaypalBraintree
4
6
  class Gateway < ::Spree::PaymentMethod
7
+ include RequestProtection
8
+
9
+ class TokenGenerationDisabledError < StandardError; end
10
+
11
+ # Error message from Braintree that gets returned by a non voidable transaction
12
+ NON_VOIDABLE_STATUS_ERROR_REGEXP = /can only be voided if status is authorized/.freeze
13
+
5
14
  TOKEN_GENERATION_DISABLED_MESSAGE = 'Token generation is disabled.' \
6
- ' To re-enable set the `token_generation_enabled` preference on the' \
7
- ' gateway to `true`.'.freeze
15
+ ' To re-enable set the `token_generation_enabled` preference on the' \
16
+ ' gateway to `true`.'
8
17
 
9
18
  ALLOWED_BRAINTREE_OPTIONS = [
10
19
  :device_data,
11
20
  :device_session_id,
12
21
  :merchant_account_id,
13
22
  :order_id
14
- ]
23
+ ].freeze
15
24
 
16
25
  VOIDABLE_STATUSES = [
17
26
  Braintree::Transaction::Status::SubmittedForSettlement,
27
+ Braintree::Transaction::Status::SettlementPending,
18
28
  Braintree::Transaction::Status::Authorized
19
29
  ].freeze
20
30
 
@@ -29,9 +39,37 @@ module SolidusPaypalBraintree
29
39
  preference(:merchant_id, :string, default: nil)
30
40
  preference(:public_key, :string, default: nil)
31
41
  preference(:private_key, :string, default: nil)
42
+ preference(:http_open_timeout, :integer, default: 60)
43
+ preference(:http_read_timeout, :integer, default: 60)
32
44
  preference(:merchant_currency_map, :hash, default: {})
33
45
  preference(:paypal_payee_email_map, :hash, default: {})
34
46
 
47
+ # Which checkout flow to use (vault/checkout)
48
+ preference(:paypal_flow, :string, default: 'vault')
49
+
50
+ # A hash that gets passed to the `style` key when initializing the credit card fields.
51
+ # See https://developers.braintreepayments.com/guides/hosted-fields/styling/javascript/v3
52
+ preference(:credit_card_fields_style, :hash, default: {})
53
+
54
+ # A hash that gets its keys passed to the associated braintree field placeholder tag.
55
+ # Example: { number: "Enter card number", cvv: "Enter CVV", expirationDate: "mm/yy" }
56
+ preference(:placeholder_text, :hash, default: {})
57
+
58
+ # Wether to use the JS device data collector
59
+ preference(:use_data_collector, :boolean, default: true)
60
+
61
+ # Useful for testing purposes, as PayPal will show funding sources based on the buyer's country;
62
+ # usually retrieved by their ip geolocation. I.e. Venmo will show for US buyers, but not European.
63
+ preference(:force_buyer_country, :string)
64
+
65
+ preference(:enable_venmo_funding, :boolean, default: false)
66
+
67
+ # When on mobile, paying with Venmo, the user may be returned to the same store tab
68
+ # depending on if their browser supports it, otherwise a new tab will be created
69
+ # However, returning to a new tab may break the payment checkout flow for some stores, for example,
70
+ # if they are single-page applications (SPA). Set this to false if this is the case
71
+ preference(:venmo_new_tab_support, :boolean, default: true)
72
+
35
73
  def partial_name
36
74
  "paypal_braintree"
37
75
  end
@@ -51,7 +89,9 @@ module SolidusPaypalBraintree
51
89
  merchant_id: preferred_merchant_id,
52
90
  public_key: preferred_public_key,
53
91
  private_key: preferred_private_key,
54
- logger: Braintree::Configuration.logger.clone
92
+ http_open_timeout: preferred_http_open_timeout,
93
+ http_read_timeout: preferred_http_read_timeout,
94
+ logger: logger
55
95
  }
56
96
  end
57
97
 
@@ -64,12 +104,14 @@ module SolidusPaypalBraintree
64
104
  # extra options to send along. e.g.: device data for fraud prevention
65
105
  # @return [Response]
66
106
  def purchase(money_cents, source, gateway_options)
67
- result = braintree.transaction.sale(
68
- amount: dollars(money_cents),
69
- **transaction_options(source, gateway_options, true)
70
- )
107
+ protected_request do
108
+ result = braintree.transaction.sale(
109
+ amount: dollars(money_cents),
110
+ **transaction_options(source, gateway_options, submit_for_settlement: true)
111
+ )
71
112
 
72
- Response.build(result)
113
+ Response.build(result)
114
+ end
73
115
  end
74
116
 
75
117
  # Authorize a payment to be captured later.
@@ -81,12 +123,14 @@ module SolidusPaypalBraintree
81
123
  # extra options to send along. e.g.: device data for fraud prevention
82
124
  # @return [Response]
83
125
  def authorize(money_cents, source, gateway_options)
84
- result = braintree.transaction.sale(
85
- amount: dollars(money_cents),
86
- **transaction_options(source, gateway_options)
87
- )
126
+ protected_request do
127
+ result = braintree.transaction.sale(
128
+ amount: dollars(money_cents),
129
+ **transaction_options(source, gateway_options)
130
+ )
88
131
 
89
- Response.build(result)
132
+ Response.build(result)
133
+ end
90
134
  end
91
135
 
92
136
  # Collect funds from an authorized payment.
@@ -97,11 +141,13 @@ module SolidusPaypalBraintree
97
141
  # @param response_code [String] the transaction id of the payment to capture
98
142
  # @return [Response]
99
143
  def capture(money_cents, response_code, _gateway_options)
100
- result = braintree.transaction.submit_for_settlement(
101
- response_code,
102
- dollars(money_cents)
103
- )
104
- Response.build(result)
144
+ protected_request do
145
+ result = braintree.transaction.submit_for_settlement(
146
+ response_code,
147
+ dollars(money_cents)
148
+ )
149
+ Response.build(result)
150
+ end
105
151
  end
106
152
 
107
153
  # Used to refeund a customer for an already settled transaction.
@@ -111,11 +157,13 @@ module SolidusPaypalBraintree
111
157
  # @param response_code [String] the transaction id of the payment to refund
112
158
  # @return [Response]
113
159
  def credit(money_cents, _source, response_code, _gateway_options)
114
- result = braintree.transaction.refund(
115
- response_code,
116
- dollars(money_cents)
117
- )
118
- Response.build(result)
160
+ protected_request do
161
+ result = braintree.transaction.refund(
162
+ response_code,
163
+ dollars(money_cents)
164
+ )
165
+ Response.build(result)
166
+ end
119
167
  end
120
168
 
121
169
  # Used to cancel a transaction before it is settled.
@@ -124,8 +172,10 @@ module SolidusPaypalBraintree
124
172
  # @param response_code [String] the transaction id of the payment to void
125
173
  # @return [Response]
126
174
  def void(response_code, _source, _gateway_options)
127
- result = braintree.transaction.void(response_code)
128
- Response.build(result)
175
+ protected_request do
176
+ result = braintree.transaction.void(response_code)
177
+ Response.build(result)
178
+ end
129
179
  end
130
180
 
131
181
  # Will either refund or void the payment depending on its state.
@@ -137,7 +187,9 @@ module SolidusPaypalBraintree
137
187
  # @param response_code [String] the transaction id of the payment to void
138
188
  # @return [Response]
139
189
  def cancel(response_code)
140
- transaction = braintree.transaction.find(response_code)
190
+ transaction = protected_request do
191
+ braintree.transaction.find(response_code)
192
+ end
141
193
  if VOIDABLE_STATUSES.include?(transaction.status)
142
194
  void(response_code, nil, {})
143
195
  else
@@ -145,6 +197,32 @@ module SolidusPaypalBraintree
145
197
  end
146
198
  end
147
199
 
200
+ # Will void the payment depending on its state or return false
201
+ #
202
+ # Used by Solidus >= 2.4 instead of +cancel+
203
+ #
204
+ # If the transaction has not yet been settled, we can void the transaction.
205
+ # Otherwise, we return false so Solidus creates a refund instead.
206
+ #
207
+ # @api public
208
+ # @param payment [Spree::Payment] the payment to void
209
+ # @return [Response|FalseClass]
210
+ def try_void(payment)
211
+ transaction = braintree.transaction.find(payment.response_code)
212
+ if transaction.status.in? SolidusPaypalBraintree::Gateway::VOIDABLE_STATUSES
213
+ # Sometimes Braintree returns a voidable status although it is not voidable anymore.
214
+ # When we try to void that transaction we receive an error and need to return false
215
+ # so Solidus can create a refund instead.
216
+ begin
217
+ void(payment.response_code, nil, {})
218
+ rescue ActiveMerchant::ConnectionError => e
219
+ e.message.match(NON_VOIDABLE_STATUS_ERROR_REGEXP) ? false : raise(e)
220
+ end
221
+ else
222
+ false
223
+ end
224
+ end
225
+
148
226
  # Creates a new customer profile in Braintree
149
227
  #
150
228
  # @api public
@@ -156,7 +234,7 @@ module SolidusPaypalBraintree
156
234
  return if source.token.present? || source.customer.present? || source.nonce.nil?
157
235
 
158
236
  result = braintree.customer.create(customer_profile_params(payment))
159
- fail Spree::Core::GatewayError, result.message unless result.success?
237
+ fail ::Spree::Core::GatewayError, result.message unless result.success?
160
238
 
161
239
  customer = result.customer
162
240
 
@@ -169,12 +247,12 @@ module SolidusPaypalBraintree
169
247
  end
170
248
  end
171
249
 
250
+ # @raise [TokenGenerationDisabledError]
251
+ # If `preferred_token_generation_enabled` is false
252
+ #
172
253
  # @return [String]
173
254
  # The token that should be used along with the Braintree js-client sdk.
174
255
  #
175
- # returns an error message if `preferred_token_generation_enabled` is
176
- # set to false.
177
- #
178
256
  # @example
179
257
  # <script>
180
258
  # var token = #{Spree::Braintree::Gateway.first!.generate_token}
@@ -189,7 +267,10 @@ module SolidusPaypalBraintree
189
267
  # );
190
268
  # </script>
191
269
  def generate_token
192
- return TOKEN_GENERATION_DISABLED_MESSAGE unless preferred_token_generation_enabled
270
+ unless preferred_token_generation_enabled
271
+ raise TokenGenerationDisabledError, TOKEN_GENERATION_DISABLED_MESSAGE
272
+ end
273
+
193
274
  braintree.client_token.generate
194
275
  end
195
276
 
@@ -217,6 +298,18 @@ module SolidusPaypalBraintree
217
298
 
218
299
  private
219
300
 
301
+ # Whether to store this payment method in the PayPal Vault. This only works when the checkout
302
+ # flow is "vault", so make sure to call +super+ if you override it.
303
+ def store_in_vault
304
+ preferred_paypal_flow == 'vault'
305
+ end
306
+
307
+ def logger
308
+ Braintree::Configuration.logger.clone.tap do |logger|
309
+ logger.level = Rails.logger.level
310
+ end
311
+ end
312
+
220
313
  def dollars(cents)
221
314
  Money.new(cents).dollars
222
315
  end
@@ -229,20 +322,24 @@ module SolidusPaypalBraintree
229
322
  JSON.parse(preference_string.gsub("=>", ":"))
230
323
  end
231
324
 
232
- def convert_preference_value(value, type)
325
+ def convert_preference_value(value, type, preference_encryptor = nil)
233
326
  if type == :hash && value.is_a?(String)
234
327
  value = to_hash(value)
235
328
  end
236
- super
329
+ if method(__method__).super_method.arity == 3
330
+ super
331
+ else
332
+ super(value, type)
333
+ end
237
334
  end
238
335
 
239
- def transaction_options(source, options, submit_for_settlement = false)
336
+ def transaction_options(source, options, submit_for_settlement: false)
240
337
  params = options.select do |key, _|
241
338
  ALLOWED_BRAINTREE_OPTIONS.include?(key)
242
339
  end
243
340
 
244
341
  params[:channel] = "Solidus"
245
- params[:options] = { store_in_vault_on_success: true }
342
+ params[:options] = { store_in_vault_on_success: store_in_vault }
246
343
 
247
344
  if submit_for_settlement
248
345
  params[:options][:submit_for_settlement] = true
@@ -252,6 +349,10 @@ module SolidusPaypalBraintree
252
349
  params[:options][:paypal] = { payee_email: paypal_email }
253
350
  end
254
351
 
352
+ if source.venmo? && venmo_business_profile_id
353
+ params[:options][:venmo] = { profile_id: venmo_business_profile_id }
354
+ end
355
+
255
356
  if merchant_account_id = merchant_account_for(source, options)
256
357
  params[:merchant_account_id] = merchant_account_id
257
358
  end
@@ -299,25 +400,31 @@ module SolidusPaypalBraintree
299
400
  end
300
401
 
301
402
  def merchant_account_for(_source, options)
302
- if options[:currency]
303
- preferred_merchant_currency_map[options[:currency]]
304
- end
403
+ return unless options[:currency]
404
+
405
+ preferred_merchant_currency_map[options[:currency]]
305
406
  end
306
407
 
307
408
  def paypal_payee_email_for(source, options)
308
- if source.paypal?
309
- preferred_paypal_payee_email_map[options[:currency]]
310
- end
409
+ return unless source.paypal?
410
+
411
+ preferred_paypal_payee_email_map[options[:currency]]
311
412
  end
312
413
 
313
414
  def customer_profile_params(payment)
314
415
  params = {}
315
416
 
316
- if payment.source.try(:nonce)
417
+ if store_in_vault && payment.source.try(:nonce)
317
418
  params[:payment_method_nonce] = payment.source.nonce
318
419
  end
319
420
 
320
421
  params
321
422
  end
423
+
424
+ # override with the Venmo business profile that you want to use for transactions,
425
+ # or leave it to be nil if want Braintree to use your default account
426
+ def venmo_business_profile_id
427
+ nil
428
+ end
322
429
  end
323
430
  end
@@ -1,3 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_merchant/billing/response'
4
+ require_relative 'avs_result'
5
+
1
6
  # Response object that all actions on the gateway should return
2
7
  module SolidusPaypalBraintree
3
8
  class Response < ActiveMerchant::Billing::Response
@@ -15,36 +20,59 @@ module SolidusPaypalBraintree
15
20
 
16
21
  def build_success(result)
17
22
  transaction = result.transaction
18
-
19
- test = true
20
- authorization = transaction.id
21
- fraud_review = nil
22
- avs_result = nil
23
- cvv_result = nil
24
-
25
- options = {
26
- test: test,
27
- authorization: authorization,
28
- fraud_review: fraud_review,
29
- avs_result: avs_result,
30
- cvv_result: cvv_result
31
- }
32
-
33
- new(true, transaction.status, {}, options)
23
+ new(true, transaction.status, {}, response_options(transaction))
34
24
  end
35
25
 
36
26
  def build_failure(result)
37
- new(false, error_message(result))
27
+ transaction = result.transaction
28
+ options = response_options(transaction).update(
29
+ # For error responses we want to have the CVV code
30
+ cvv_result: transaction&.cvv_response_code
31
+ )
32
+ new(false, error_message(result), result.params, options)
33
+ end
34
+
35
+ def response_options(transaction)
36
+ # Some error responses do not have a transaction
37
+ return {} if transaction.nil?
38
+
39
+ {
40
+ authorization: transaction.id,
41
+ avs_result: SolidusPaypalBraintree::AVSResult.build(transaction),
42
+ # As we do not provide the CVV while submitting the transaction (for PCI compliance reasons),
43
+ # we need to ignore the only response we get back (I = not provided).
44
+ # Otherwise Solidus thinks this payment is risky.
45
+ cvv_result: nil
46
+ }
38
47
  end
39
48
 
40
49
  def error_message(result)
41
50
  if result.errors.any?
42
51
  result.errors.map { |e| "#{e.message} (#{e.code})" }.join(" ")
43
52
  else
44
- [result.transaction.status,
45
- result.transaction.gateway_rejection_reason,
46
- result.transaction.processor_settlement_response_code,
47
- result.transaction.processor_settlement_response_text].compact.join(" ")
53
+ transaction_error_message(result.transaction)
54
+ end
55
+ end
56
+
57
+ # Human readable error message for transaction responses
58
+ def transaction_error_message(transaction)
59
+ case transaction.status
60
+ when 'gateway_rejected'
61
+ I18n.t(transaction.gateway_rejection_reason,
62
+ scope: 'solidus_paypal_braintree.gateway_rejection_reasons',
63
+ default: "#{transaction.status.humanize} #{transaction.gateway_rejection_reason.humanize}")
64
+ when 'processor_declined'
65
+ I18n.t(transaction.processor_response_code,
66
+ scope: 'solidus_paypal_braintree.processor_response_codes',
67
+ default: "#{transaction.processor_response_text} (#{transaction.processor_response_code})")
68
+ when 'settlement_declined'
69
+ I18n.t(transaction.processor_settlement_response_code,
70
+ scope: 'solidus_paypal_braintree.processor_settlement_response_codes',
71
+ default: "#{transaction.processor_settlement_response_text} (#{transaction.processor_settlement_response_code})") # rubocop:disable Layout/LineLength
72
+ else
73
+ I18n.t(transaction.status,
74
+ scope: 'solidus_paypal_braintree.transaction_statuses',
75
+ default: transaction.status.humanize)
48
76
  end
49
77
  end
50
78
  end
@@ -1,22 +1,41 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidusPaypalBraintree
2
- class Source < ApplicationRecord
4
+ class Source < SolidusSupport.payment_source_parent_class
5
+ include RequestProtection
6
+
3
7
  PAYPAL = "PayPalAccount"
4
8
  APPLE_PAY = "ApplePayCard"
9
+ VENMO = "VenmoAccount"
5
10
  CREDIT_CARD = "CreditCard"
6
11
 
7
- belongs_to :user, class_name: Spree::UserClassHandle.new
12
+ enum paypal_funding_source: {
13
+ applepay: 0, bancontact: 1, blik: 2, boleto: 3, card: 4, credit: 5, eps: 6, giropay: 7, ideal: 8,
14
+ itau: 9, maxima: 10, mercadopago: 11, mybank: 12, oxxo: 13, p24: 14, paylater: 15, paypal: 16, payu: 17,
15
+ sepa: 18, sofort: 19, trustly: 20, venmo: 21, verkkopankki: 22, wechatpay: 23, zimpler: 24
16
+ }, _suffix: :funding
17
+
18
+ belongs_to :user, class_name: ::Spree::UserClassHandle.new, optional: true
8
19
  belongs_to :payment_method, class_name: 'Spree::PaymentMethod'
9
- has_many :payments, as: :source, class_name: "Spree::Payment"
20
+ has_many :payments, as: :source, class_name: "Spree::Payment", dependent: :destroy
10
21
 
11
- belongs_to :customer, class_name: "SolidusPaypalBraintree::Customer"
22
+ belongs_to :customer, class_name: "SolidusPaypalBraintree::Customer", optional: true
12
23
 
13
- validates :payment_type, inclusion: [PAYPAL, APPLE_PAY, CREDIT_CARD]
24
+ validates :payment_type, inclusion: [PAYPAL, APPLE_PAY, VENMO, CREDIT_CARD]
25
+
26
+ before_save :clear_paypal_funding_source, unless: :paypal?
14
27
 
15
28
  scope(:with_payment_profile, -> { joins(:customer) })
16
29
  scope(:credit_card, -> { where(payment_type: CREDIT_CARD) })
17
30
 
18
- delegate :last_4, :card_type, to: :braintree_payment_method, allow_nil: true
31
+ delegate :last_4, :card_type, :expiration_month, :expiration_year, :email,
32
+ :username, :source_description, to: :braintree_payment_method, allow_nil: true
33
+
34
+ # Aliases to match Spree::CreditCard's interface
19
35
  alias_method :last_digits, :last_4
36
+ alias_method :month, :expiration_month
37
+ alias_method :year, :expiration_year
38
+ alias_method :cc_type, :card_type
20
39
 
21
40
  # we are not currenctly supporting an "imported" flag
22
41
  def imported
@@ -32,7 +51,14 @@ module SolidusPaypalBraintree
32
51
  end
33
52
 
34
53
  def can_void?(payment)
35
- !payment.failed? && !payment.void?
54
+ return false unless payment.response_code
55
+
56
+ transaction = protected_request do
57
+ braintree_client.transaction.find(payment.response_code)
58
+ end
59
+ Gateway::VOIDABLE_STATUSES.include?(transaction.status)
60
+ rescue ActiveMerchant::ConnectionError
61
+ false
36
62
  end
37
63
 
38
64
  def can_credit?(payment)
@@ -51,23 +77,57 @@ module SolidusPaypalBraintree
51
77
  payment_type == PAYPAL
52
78
  end
53
79
 
80
+ def venmo?
81
+ payment_type == VENMO
82
+ end
83
+
84
+ def reusable?
85
+ token.present?
86
+ end
87
+
54
88
  def credit_card?
55
89
  payment_type == CREDIT_CARD
56
90
  end
57
91
 
58
92
  def display_number
59
- "XXXX-XXXX-XXXX-#{last_digits}"
93
+ if paypal?
94
+ email
95
+ elsif venmo?
96
+ username
97
+ else
98
+ "XXXX-XXXX-XXXX-#{last_digits.to_s.rjust(4, 'X')}"
99
+ end
100
+ end
101
+
102
+ def display_paypal_funding_source
103
+ I18n.t(paypal_funding_source,
104
+ scope: 'solidus_paypal_braintree.paypal_funding_sources',
105
+ default: paypal_funding_source)
106
+ end
107
+
108
+ def display_payment_type
109
+ "#{I18n.t('solidus_paypal_braintree.payment_type.label')}: #{friendly_payment_type}"
60
110
  end
61
111
 
62
112
  private
63
113
 
64
114
  def braintree_payment_method
65
- return unless braintree_client && credit_card?
66
- @braintree_payment_method ||= braintree_client.payment_method.find(token)
115
+ return unless braintree_client
116
+
117
+ @braintree_payment_method ||= protected_request do
118
+ braintree_client.payment_method.find(token)
119
+ end
120
+ rescue ActiveMerchant::ConnectionError, ArgumentError => e
121
+ Rails.logger.warn("#{e}: token unknown or missing for #{inspect}")
122
+ nil
67
123
  end
68
124
 
69
125
  def braintree_client
70
126
  @braintree_client ||= payment_method.try(:braintree)
71
127
  end
128
+
129
+ def clear_paypal_funding_source
130
+ self.paypal_funding_source = nil
131
+ end
72
132
  end
73
133
  end