flowcommerce_spree 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +91 -0
  4. data/Rakefile +33 -0
  5. data/SPREE_FLOW.md +134 -0
  6. data/app/assets/javascripts/flowcommerce_spree/application.js +13 -0
  7. data/app/assets/stylesheets/flowcommerce_spree/application.css +15 -0
  8. data/app/controllers/concerns/current_zone_loader_decorator.rb +49 -0
  9. data/app/controllers/flowcommerce_spree/webhooks_controller.rb +25 -0
  10. data/app/helpers/flowcommerce_spree/application_helper.rb +6 -0
  11. data/app/helpers/spree/admin/orders_helper_decorator.rb +17 -0
  12. data/app/helpers/spree/core/controller_helpers/flow_io_order_helper_decorator.rb +53 -0
  13. data/app/mailers/spree/spree_order_mailer_decorator.rb +24 -0
  14. data/app/models/flowcommerce_spree/settings.rb +8 -0
  15. data/app/models/spree/credit_card_decorator.rb +9 -0
  16. data/app/models/spree/flow_io_product_decorator.rb +91 -0
  17. data/app/models/spree/flow_io_variant_decorator.rb +205 -0
  18. data/app/models/spree/gateway/spree_flow_gateway.rb +116 -0
  19. data/app/models/spree/line_item_decorator.rb +15 -0
  20. data/app/models/spree/order_decorator.rb +179 -0
  21. data/app/models/spree/promotion_decorator.rb +10 -0
  22. data/app/models/spree/promotion_handler/coupon_decorator.rb +30 -0
  23. data/app/models/spree/spree_user_decorator.rb +15 -0
  24. data/app/models/spree/taxon_decorator.rb +37 -0
  25. data/app/models/spree/zone_decorator.rb +7 -0
  26. data/app/models/spree/zones/flow_io_product_zone_decorator.rb +55 -0
  27. data/app/services/flowcommerce_spree/import_experience_items.rb +76 -0
  28. data/app/services/flowcommerce_spree/import_experiences.rb +37 -0
  29. data/app/services/flowcommerce_spree/order_sync.rb +231 -0
  30. data/app/views/layouts/flowcommerce_spree/application.html.erb +14 -0
  31. data/app/views/spree/admin/payments/index.html.erb +28 -0
  32. data/app/views/spree/admin/promotions/edit.html.erb +57 -0
  33. data/app/views/spree/admin/shared/_order_summary.html.erb +44 -0
  34. data/app/views/spree/admin/shared/_order_summary_flow.html.erb +13 -0
  35. data/app/views/spree/order_mailer/confirm_email.html.erb +86 -0
  36. data/app/views/spree/order_mailer/confirm_email.text.erb +38 -0
  37. data/config/initializers/flowcommerce_spree.rb +7 -0
  38. data/config/routes.rb +5 -0
  39. data/db/migrate/20201021160159_add_type_and_meta_to_spree_zone.rb +23 -0
  40. data/db/migrate/20201021755957_add_meta_to_spree_tables.rb +17 -0
  41. data/db/migrate/20201022173210_add_zone_type_to_spree_zone_members.rb +24 -0
  42. data/db/migrate/20201022174252_add_kind_to_zone.rb +22 -0
  43. data/lib/flow/error.rb +73 -0
  44. data/lib/flow/pay_pal.rb +25 -0
  45. data/lib/flow/simple_gateway.rb +115 -0
  46. data/lib/flowcommerce_spree.rb +31 -0
  47. data/lib/flowcommerce_spree/api.rb +48 -0
  48. data/lib/flowcommerce_spree/engine.rb +27 -0
  49. data/lib/flowcommerce_spree/experience_service.rb +65 -0
  50. data/lib/flowcommerce_spree/logging_http_client.rb +43 -0
  51. data/lib/flowcommerce_spree/logging_http_handler.rb +15 -0
  52. data/lib/flowcommerce_spree/refresher.rb +81 -0
  53. data/lib/flowcommerce_spree/session.rb +71 -0
  54. data/lib/flowcommerce_spree/version.rb +5 -0
  55. data/lib/flowcommerce_spree/webhook_service.rb +98 -0
  56. data/lib/simple_csv_writer.rb +44 -0
  57. data/lib/tasks/flowcommerce_spree.rake +289 -0
  58. metadata +220 -0
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ OrderMailer.class_eval do
5
+ # default from: ApplicationMailer::DEFAULT_FROM
6
+
7
+ def refund_complete_email(web_hook_event)
8
+ auth_id = web_hook_event.dig('refund', 'authorization', 'key')
9
+
10
+ raise Flow::Error, 'authorization key not found in WebHookEvent [refund_capture_upserted_v2]' unless auth_id
11
+
12
+ authorization = FlowcommerceSpree.client.authorizations.get_by_key FlowcommerceSpree::ORGANIZATION, auth_id
13
+
14
+ refund_requested = web_hook_event['refund']['requested']
15
+ @mail_to = authorization.customer.email
16
+ @full_name = "#{authorization.customer.name.first} #{authorization.customer.name.last}"
17
+ @amount = "#{refund_requested['amount']} #{refund_requested['currency']}"
18
+ @number = authorization.order.number
19
+ @order = Spree::Order.find_by number: @number
20
+
21
+ mail(to: @mail_to, subject: "We refunded your order for ammount #{@amount}")
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowcommerceSpree
4
+ class Settings < Spree::Preferences::Configuration
5
+ preference :additional_attributes, :hash, default: {}
6
+ preference :product_catalog_upload, :hash, default: {}
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ CreditCard.class_eval do
5
+ serialize :meta, ActiveRecord::Coders::JSON.new(symbolize_keys: true)
6
+
7
+ store_accessor :meta, :flow_data
8
+ end
9
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Flow specific methods for Spree::Product
4
+ module Spree
5
+ module FlowIoProductDecorator
6
+ def self.prepended(base)
7
+ base.serialize :meta, ActiveRecord::Coders::JSON.new(symbolize_keys: true)
8
+
9
+ base.store_accessor :meta, :flow_data, :zone_ids
10
+ end
11
+
12
+ def price_in_zone(currency, product_zone)
13
+ flow_experience_key = product_zone&.flow_data&.[]('key')
14
+ return flow_local_price(flow_experience_key) if flow_experience_key.present?
15
+
16
+ price_in(currency)
17
+ end
18
+
19
+ # returns price bound to local experience from master variant
20
+ def flow_local_price(flow_exp)
21
+ master.flow_local_price(flow_exp) || Spree::Price.new(variant_id: id, currency: 'USD', amount: 0)
22
+ end
23
+
24
+ def flow_included?(flow_exp)
25
+ return true unless flow_exp
26
+
27
+ flow_data["#{flow_exp.key}.excluded"].to_i != 1
28
+ end
29
+
30
+ def price_range(product_zone)
31
+ prices = {}
32
+ master_prices.each do |p|
33
+ currency = p.currency
34
+ min = nil
35
+ max = nil
36
+
37
+ if variants.any?
38
+ variants.each do |v|
39
+ price = v.price_in(currency)
40
+ next if price.nil? || price.amount.nil?
41
+
42
+ min = price if min.nil? || min.amount > price.amount
43
+ max = price if max.nil? || max.amount < price.amount
44
+ end
45
+ else
46
+ min = max = master.price_in(currency)
47
+ end
48
+
49
+ rmin = min&.amount&.to_s(:rounded, precision: 0) || 0
50
+ rmax = max&.amount&.to_s(:rounded, precision: 0) || 0
51
+
52
+ prices[currency] = rmin == rmax ? { amount: rmin } : { min: rmin, max: rmax }
53
+ end
54
+
55
+ add_flow_price_range(prices, product_zone)
56
+ end
57
+
58
+ def add_flow_price_range(prices, product_zone)
59
+ flow_experience_key = product_zone&.flow_data&.[]('key')
60
+ return prices if flow_experience_key.blank?
61
+
62
+ master_price = master.flow_local_price(flow_experience_key)
63
+ currency = product_zone.flow_io_experience_currency
64
+ min = nil
65
+ max = nil
66
+
67
+ if variants.any?
68
+ variants.each do |v|
69
+ price = v.flow_local_price(flow_experience_key)
70
+ next if price.amount.nil? || price.currency != currency
71
+
72
+ min = price if min.nil? || min.amount > price.amount
73
+ max = price if max.nil? || max.amount < price.amount
74
+ end
75
+ end
76
+
77
+ if master_price.currency == currency
78
+ min ||= master_price
79
+ max ||= master_price
80
+ end
81
+
82
+ rmin = min&.amount&.to_s(:rounded, precision: 0) || 0
83
+ rmax = max&.amount&.to_s(:rounded, precision: 0) || 0
84
+
85
+ prices[currency] = rmin == rmax ? { amount: rmin } : { min: rmin, max: rmax }
86
+ prices
87
+ end
88
+
89
+ Spree::Product.prepend(self) if Spree::Product.included_modules.exclude?(self)
90
+ end
91
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Flow specific methods for Spree::Variant
4
+ # Spree save all the prices inside Variant object. We choose to have a cache jsonb field named flow_data that will
5
+ # hold all important Flow sync data for specific experiences.
6
+ module Spree
7
+ module FlowIoVariantDecorator
8
+ def self.prepended(base)
9
+ base.serialize :meta, ActiveRecord::Coders::JSON.new(symbolize_keys: true)
10
+
11
+ base.store_accessor :meta, :flow_data
12
+
13
+ # after every save we sync product we generate sh1 checksums to update only when change happend
14
+ base.after_save :sync_product_to_flow
15
+ end
16
+
17
+ def experiences
18
+ flow_data&.[]('exp')
19
+ end
20
+
21
+ def add_flow_io_experience_data(exp, value)
22
+ raise ArgumentError, 'Value should be a hash' unless value.is_a?(Hash)
23
+
24
+ self.flow_data = flow_data || {}
25
+ self.flow_data['exp'] ||= {}
26
+ self.flow_data['exp'][exp] = value
27
+ end
28
+
29
+ # clears flow_data from the records
30
+ def truncate_flow_data
31
+ flow_data&.[]('exp')&.keys&.each do |exp_key|
32
+ break unless (product = self.product)
33
+
34
+ remove_experience_from_product(exp_key, product)
35
+ end
36
+
37
+ meta.delete(:flow_data)
38
+ update_column(:meta, meta.to_json)
39
+ end
40
+
41
+ def remove_experience_from_product(exp_key, product)
42
+ return unless (zone = Spree::Zones::Product.find_by(name: exp_key.titleize))
43
+
44
+ zone_ids = product.zone_ids || []
45
+ zone_id_string = zone.id.to_s
46
+ return unless zone_ids.include?(zone_id_string)
47
+
48
+ product.zone_ids = zone_ids - [zone_id_string]
49
+ product.update_columns(meta: product.meta.to_json)
50
+ end
51
+
52
+ # upload product variant to Flow's Product Catalog
53
+ def sync_product_to_flow
54
+ # initial Spree seed will fail, so skip unless we have Flow data field
55
+ return unless respond_to?(:flow_data)
56
+
57
+ return if FlowcommerceSpree::API_KEY.blank? || FlowcommerceSpree::API_KEY == 'test_key'
58
+
59
+ return { error: 'Price is 0' } if price == 0
60
+
61
+ additional_attrs = {}
62
+ attr_name = nil
63
+ export_required = false
64
+ FlowcommerceSpree::Config.additional_attributes[self.class.name.tableize.tr('/', '_').to_sym]&.each do |attr_item|
65
+ attr_name = attr_item[0]
66
+ # Flow.io could require a different attribute name, as in case of Fulfil's :customs_description - it has the
67
+ # export_name `:materials` for flow.io. That's why 1st we're checking if an export_name is defined for the
68
+ # attribute.
69
+ attr_flowcommerce_name = attr_item[1][:export_name] || attr_name
70
+ export_required = attr_item[1][:export] == :required
71
+ attr_value = __send__(attr_name)
72
+ break if export_required && attr_value.blank?
73
+
74
+ additional_attrs[attr_flowcommerce_name] = attr_value if attr_value
75
+ end
76
+
77
+ if export_required && additional_attrs[attr_value].blank?
78
+ return { error: "Variant with sku = #{sku} has no #{attr_name}" }
79
+ end
80
+
81
+ flow_item = to_flowcommerce_item(additional_attrs)
82
+ flow_item_sh1 = Digest::SHA1.hexdigest(flow_item.to_json)
83
+
84
+ # skip if sync not needed
85
+ return nil if flow_data&.[](:last_sync_sh1) == flow_item_sh1
86
+
87
+ response = FlowcommerceSpree.client.items.put_by_number(FlowcommerceSpree::ORGANIZATION, sku, flow_item)
88
+ self.flow_data ||= {}
89
+ self.flow_data[:last_sync_sh1] = flow_item_sh1
90
+
91
+ # after successful put, write cache
92
+ update_column(:meta, meta.to_json)
93
+
94
+ response
95
+ rescue Net::OpenTimeout => e
96
+ { error: e.message }
97
+ end
98
+
99
+ def flow_prices(flow_exp)
100
+ flow_data&.dig(:exp, flow_exp, :prices) || []
101
+ end
102
+
103
+ # returns price bound to local experience
104
+ def flow_local_price(flow_exp)
105
+ price_object = flow_prices(flow_exp)&.first
106
+ amount = price_object&.[](:amount) || price
107
+ currency = price_object&.[](:currency) || cost_currency
108
+ Spree::Price.new(variant_id: id, currency: currency, amount: amount)
109
+ end
110
+
111
+ def price_in_zone(currency, product_zone)
112
+ flow_experience_key = product_zone&.flow_data&.[]('key')
113
+ return flow_local_price(flow_experience_key) if flow_experience_key.present?
114
+
115
+ price_in(currency)
116
+ end
117
+
118
+ def all_prices_in_zone(product_zone)
119
+ all_prices = prices.map { |price| { currency: price.currency, amount: (price.amount&.round || 0).to_s } }
120
+
121
+ flow_experience_key = product_zone&.flow_data&.[]('key')
122
+ return all_prices if flow_experience_key.blank?
123
+
124
+ flow_price = flow_local_price(flow_experience_key)
125
+ all_prices << { currency: flow_price.currency, amount: (flow_price.amount&.round || 0).to_s }
126
+ all_prices
127
+ end
128
+
129
+ # creates object for flow api
130
+ def to_flowcommerce_item(additional_attrs)
131
+ # add product categories
132
+ categories = []
133
+ taxon = product.taxons.first
134
+ current_taxon = taxon
135
+ while current_taxon
136
+ categories.unshift current_taxon.name
137
+ current_taxon = current_taxon.parent
138
+ end
139
+
140
+ images = if (image = product.images.first || product.variant_images.first)
141
+ asset_host_scheme = ENV.fetch('ASSET_HOST_PROTOCOL', 'https')
142
+ asset_host = ENV.fetch('ASSET_HOST', 'staging.mejuri.com')
143
+ large_image_uri = URI(image.attachment(:large))
144
+ product_image_uri = URI(image.attachment.url(:product))
145
+ large_image_uri.scheme ||= asset_host_scheme
146
+ product_image_uri.scheme ||= asset_host_scheme
147
+ large_image_uri.host ||= asset_host
148
+ product_image_uri.host ||= asset_host
149
+
150
+ [{ url: large_image_uri.to_s, tags: ['checkout'] },
151
+ { url: product_image_uri.to_s, tags: ['thumbnail'] }]
152
+ else
153
+ []
154
+ end
155
+
156
+ Io::Flow::V0::Models::ItemForm.new(
157
+ number: sku,
158
+ locale: 'en_US',
159
+ language: 'en',
160
+ name: product.name,
161
+ description: product.description,
162
+ currency: cost_currency,
163
+ price: price.to_f,
164
+ images: images,
165
+ categories: categories,
166
+ attributes: common_attrs(taxon).merge!(additional_attrs)
167
+ )
168
+ end
169
+
170
+ def common_attrs(taxon)
171
+ {
172
+ weight: weight.to_s,
173
+ height: height.to_s,
174
+ width: width.to_s,
175
+ depth: depth.to_s,
176
+ is_master: is_master ? 'true' : 'false',
177
+ product_id: product_id.to_s,
178
+ tax_category: product.tax_category_id.to_s,
179
+ product_description: product.description,
180
+ product_shipping_category: product.shipping_category_id ? shipping_category.name : nil,
181
+ product_meta_title: taxon&.meta_title.to_s,
182
+ product_meta_description: taxon&.meta_description.to_s,
183
+ product_meta_keywords: taxon&.meta_keywords.to_s,
184
+ product_slug: product.slug
185
+ }.select { |_k, v| v.present? }
186
+ end
187
+
188
+ # gets flow catalog item, and imports it
189
+ # called from flow:sync_localized_items rake task
190
+ def flow_import_item(item_hash, experience_key: nil)
191
+ # If experience not specified, get it from the local hash of imported variant
192
+ experience_key ||= item_hash.dig(:local, :experience, :key)
193
+ current_experience_meta = item_hash.delete(:local)
194
+
195
+ # Do not repeatedly store Experience data - this is stored in Spree::Zones::Product
196
+ current_experience_meta.delete(:experience)
197
+ add_flow_io_experience_data(experience_key, current_experience_meta)
198
+ self.flow_data.merge!(item_hash)
199
+
200
+ update_column(:meta, meta.to_json)
201
+ end
202
+
203
+ Spree::Variant.prepend(self) if Spree::Variant.included_modules.exclude?(self)
204
+ end
205
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Flow.io (2017)
4
+ # adapter for Spree that talks to activemerchant_flow
5
+ module Spree
6
+ class Gateway
7
+ class Flow < Gateway
8
+ def provider_class
9
+ self.class
10
+ end
11
+
12
+ def actions
13
+ %w[capture authorize purchase refund void]
14
+ end
15
+
16
+ # if user wants to force auto capture
17
+ def auto_capture?
18
+ false
19
+ end
20
+
21
+ def payment_profiles_supported?
22
+ true
23
+ end
24
+
25
+ def method_type
26
+ 'gateway'
27
+ end
28
+
29
+ def preferences
30
+ {}
31
+ end
32
+
33
+ def supports?(source)
34
+ # flow supports credit cards
35
+ source.class == Spree::CreditCard
36
+ end
37
+
38
+ def authorize(_amount, _payment_method, options = {})
39
+ order = load_order options
40
+ order.cc_authorization
41
+ end
42
+
43
+ def capture(_amount, _payment_method, options = {})
44
+ order = load_order options
45
+ order.cc_capture
46
+ end
47
+
48
+ def purchase(_amount, _payment_method, options = {})
49
+ order = load_order options
50
+ flow_auth = order.cc_authorization
51
+
52
+ if flow_auth.success?
53
+ order.cc_capture
54
+ else
55
+ flow_auth
56
+ end
57
+ end
58
+
59
+ def refund(_money, _authorization_key, options = {})
60
+ order = load_order options
61
+ order.cc_refund
62
+ end
63
+
64
+ def void(money, authorization_key, options = {})
65
+ # binding.pry
66
+ end
67
+
68
+ def create_profile(payment)
69
+ # binding.pry
70
+
71
+ # payment.order.state
72
+ @credit_card = payment.source
73
+
74
+ profile_ensure_payment_method_is_present!
75
+ create_flow_cc_profile!
76
+ end
77
+
78
+ private
79
+
80
+ # hard inject Flow as payment method unless defined
81
+ def profile_ensure_payment_method_is_present!
82
+ return if @credit_card.payment_method_id
83
+
84
+ flow_payment = Spree::PaymentMethod.where(active: true, type: 'Spree::Gateway::Flow').first
85
+ @credit_card.payment_method_id = flow_payment.id if flow_payment
86
+ end
87
+
88
+ # create payment profile with Flow and tokenize Credit Card
89
+ def create_flow_cc_profile!
90
+ return if @credit_card.gateway_customer_profile_id
91
+ return unless @credit_card.verification_value
92
+
93
+ # build credit card hash
94
+ data = {}
95
+ data[:number] = @credit_card.number
96
+ data[:name] = @credit_card.name
97
+ data[:cvv] = @credit_card.verification_value
98
+ data[:expiration_year] = @credit_card.year.to_i
99
+ data[:expiration_month] = @credit_card.month.to_i
100
+
101
+ # tokenize with Flow
102
+ # rescue Io::Flow::V0::HttpClient::ServerError
103
+ card_form = ::Io::Flow::V0::Models::CardForm.new(data)
104
+ result = FlowcommerceSpree.client.cards.post(::FlowcommerceSpree::ORGANIZATION, card_form)
105
+
106
+ @credit_card.update_column :gateway_customer_profile_id, result.token
107
+ end
108
+
109
+ def load_order(options)
110
+ order_number = options[:order_id].split('-').first
111
+ spree_order = Spree::Order.find_by number: order_number
112
+ ::Flow::SimpleGateway.new spree_order
113
+ end
114
+ end
115
+ end
116
+ end