flowcommerce_spree 0.0.1

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