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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Flow (2017)
4
+ # Enable this modifications if you want to display flow localized line item
5
+ # Example: https://i.imgur.com/7v2ix2G.png
6
+ module Spree
7
+ LineItem.class_eval do
8
+ # admin show line item price
9
+ def single_money
10
+ price = display_price.to_s
11
+ price += " (#{order.flow_line_item_price(self)})" if order.flow_order
12
+ price
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `:display_total` modifications to display total prices beside Spree default. Example: https://i.imgur.com/7v2ix2G.png
4
+ module Spree # rubocop:disable Metrics/ModuleLength
5
+ # Added flow specific methods to Spree::Order
6
+ Order.class_eval do
7
+ serialize :meta, ActiveRecord::Coders::JSON.new(symbolize_keys: true)
8
+
9
+ store_accessor :meta, :flow_data
10
+
11
+ before_save :sync_to_flow_io
12
+ after_touch :sync_to_flow_io
13
+
14
+ def sync_to_flow_io
15
+ return unless zone&.flow_io_active_experience? && state == 'cart' && line_items.size > 0
16
+
17
+ flow_io_order = FlowcommerceSpree::OrderSync.new(order: self)
18
+ flow_io_order.build_flow_request
19
+ flow_io_order.synchronize! if flow_data['digest'] != flow_io_order.digest
20
+ end
21
+
22
+ def display_total
23
+ price = FlowcommerceSpree::Api.format_default_price total
24
+ price += " (#{flow_total})" if flow_order
25
+ price.html_safe
26
+ end
27
+
28
+ def flow_order
29
+ return unless flow_data&.[]('order')
30
+
31
+ Hashie::Mash.new flow_data['order']
32
+ end
33
+
34
+ # accepts line item, usually called from views
35
+ def flow_line_item_price(line_item, total = false)
36
+ result = if flow_order
37
+ id = line_item.variant.sku
38
+
39
+ lines = flow_order.lines || []
40
+ item = lines.find { |el| el['item_number'] == id }
41
+
42
+ return 'n/a' unless item
43
+
44
+ total ? item['total']['label'] : item['price']['label']
45
+ else
46
+ FlowcommerceSpree::Api.format_default_price(line_item.price * (total ? line_item.quantity : 1))
47
+ end
48
+
49
+ # add line item promo
50
+ # promo_total, adjustment_total
51
+ result += " (#{FlowcommerceSpree::Api.format_default_price(line_item.promo_total)})" if line_item.promo_total > 0
52
+
53
+ result
54
+ end
55
+
56
+ # prepares array of prices that can be easily renderd in templates
57
+ def flow_cart_breakdown
58
+ prices = []
59
+
60
+ price_model = Struct.new(:name, :label)
61
+
62
+ if flow_order
63
+ # duty, vat, ...
64
+ unless flow_order.prices
65
+ message = Flow::Error.format_order_message flow_order
66
+ raise Flow::Error, message
67
+ end
68
+
69
+ flow_order.prices.each do |price|
70
+ prices.push price_model.new(price['name'], price['label'])
71
+ end
72
+ else
73
+ price_elements =
74
+ %i[item_total adjustment_total included_tax_total additional_tax_total tax_total shipment_total promo_total]
75
+ price_elements.each do |el|
76
+ price = send(el)
77
+ if price > 0
78
+ label = FlowcommerceSpree::Api.format_default_price price
79
+ prices.push price_model.new(el.to_s.humanize.capitalize, label)
80
+ end
81
+ end
82
+
83
+ # discount is applied and we allways show it in default currency
84
+ if adjustment_total != 0
85
+ formated_discounted_price = FlowcommerceSpree::Api.format_default_price adjustment_total
86
+ prices.push price_model.new('Discount', formated_discounted_price)
87
+ end
88
+ end
89
+
90
+ # total
91
+ prices.push price_model.new(Spree.t(:total), flow_total)
92
+
93
+ prices
94
+ end
95
+
96
+ # shows localized total, if possible. if not, fall back to Spree default
97
+ def flow_total
98
+ # r flow_order.total.label
99
+ price = flow_order&.total&.label
100
+ price || FlowcommerceSpree::Api.format_default_price(total)
101
+ end
102
+
103
+ def flow_experience
104
+ model = Struct.new(:key)
105
+ model.new flow_order.experience.key
106
+ rescue StandardError => _e
107
+ model.new ENV.fetch('FLOW_BASE_COUNTRY')
108
+ end
109
+
110
+ def flow_io_experience_key
111
+ flow_data&.[]('exp')
112
+ end
113
+
114
+ def flow_io_experience_from_zone
115
+ self.flow_data = (flow_data || {}).merge!('exp' => zone.flow_io_experience)
116
+ end
117
+
118
+ def flow_io_order_id
119
+ flow_data&.dig('order', 'id')
120
+ end
121
+
122
+ def flow_io_attributes
123
+ flow_data&.dig('order', 'attributes') || {}
124
+ end
125
+
126
+ def add_user_consent_to_flow_data(consent, value)
127
+ self.flow_data['order'] ||= {}
128
+ self.flow_data['order']['attributes'] ||= {}
129
+ self.flow_data['order']['attributes'][consent] = value
130
+ end
131
+
132
+ def add_user_uuid_to_flow_data
133
+ self.flow_data['order'] ||= {}
134
+ self.flow_data['order']['attributes'] ||= {}
135
+ self.flow_data['order']['attributes']['user_uuid'] = user&.uuid
136
+ end
137
+
138
+ def flow_io_user_uuid
139
+ flow_data&.dig('order', 'attributes', 'user_uuid')
140
+ end
141
+
142
+ def checkout_url
143
+ "https://checkout.flow.io/#{FlowcommerceSpree::ORGANIZATION}/checkout/#{number}/" \
144
+ "contact-info?flow_session_id=#{flow_data['session_id']}"
145
+ end
146
+
147
+ # clear invalid zero amount payments. Solidus bug?
148
+ def clear_zero_amount_payments!
149
+ # class attribute that can be set to true
150
+ return unless Flow::Order.clear_zero_amount_payments
151
+
152
+ payments.where(amount: 0, state: %w[invalid processing pending]).map(&:destroy)
153
+ end
154
+
155
+ def flow_order_authorized?
156
+ flow_data&.[]('authorization') ? true : false
157
+ end
158
+
159
+ def flow_order_captured?
160
+ flow_data['capture'] ? true : false
161
+ end
162
+
163
+ # completes order and sets all states to finalized and complete
164
+ # used when we have confirmed capture from Flow API or PayPal
165
+ def flow_finalize!
166
+ finalize! unless state == 'complete'
167
+ update_column :payment_state, 'paid' if payment_state != 'paid'
168
+ update_column :state, 'complete' if state != 'complete'
169
+ end
170
+
171
+ def flow_payment_method
172
+ if flow_data['payment_type'] == 'paypal'
173
+ 'paypal'
174
+ else
175
+ 'cc' # creait card is default
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Flow specific methods for Spree::Promotion
4
+ module Spree
5
+ Promotion.class_eval do
6
+ serialize :meta, ActiveRecord::Coders::JSON.new(symbolize_keys: true)
7
+
8
+ store_accessor :meta, :flow_data
9
+ end
10
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PromotionHandler
5
+ Coupon.class_eval do
6
+ def apply
7
+ if order.coupon_code.present?
8
+ if promotion&.actions.exists?
9
+ experience_key = order.flow_order&.dig('experience', 'key')
10
+ forbiden_keys = promotion.flow_data&.dig('filter', 'experience') || []
11
+
12
+ if experience_key.present? && !forbiden_keys.include?(experience_key)
13
+ self.error = 'Promotion is not available in current country'
14
+ else
15
+ handle_present_promotion(promotion)
16
+ end
17
+ else
18
+ self.error = if Promotion.with_coupon_code(order.coupon_code)&.expired?
19
+ Spree.t(:coupon_code_expired)
20
+ else
21
+ Spree.t(:coupon_code_not_found)
22
+ end
23
+ end
24
+ end
25
+
26
+ self
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # added flow specific methods to Spree.user_class
4
+ # which is for Spree in same time
5
+ # - user object (for admins as well)
6
+ # - customer object
7
+
8
+ Spree.user_class.class_eval do
9
+ def flow_number
10
+ return unless id
11
+
12
+ token = ENV.fetch('ENCRYPTION_KEY')
13
+ "su-#{Digest::SHA1.hexdigest(format('%d-%s', id, token))}"
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # module Spree
4
+ # Taxon.class_eval do
5
+ # def products_by_zone(product_zone)
6
+ # flow_experience_key = product_zone.flow_data&.[]('key')
7
+ # sku_regex = product_zone.sku_regex
8
+ #
9
+ # if flow_experience_key.present?
10
+ # products_by_experience(product_zone, sku_regex)
11
+ # else
12
+ # products.joins(:master).where('spree_variants.sku ~ ?', sku_regex)
13
+ # end
14
+ # end
15
+ #
16
+ # def products_by_experience(flow_experience_key, sku_regex)
17
+ # # To make the following query return a distinct array of products, raw SQL had to be used:
18
+ # # object.products.joins(:variants).where(
19
+ # # "spree_variants.meta -> 'flow_data' -> 'exp' ->> '#{flow_experience_key}' IS NOT NULL"
20
+ # # )
21
+ # query = <<~SQL
22
+ # SELECT DISTINCT spree_products.* FROM spree_products
23
+ # INNER JOIN spree_variants ON spree_variants.product_id = spree_products.id AND
24
+ # spree_variants.is_master = 'f' AND spree_variants.deleted_at IS NULL AND
25
+ # (spree_variants.sku ~ '#{sku_regex}')
26
+ # INNER JOIN (
27
+ # SELECT spree_products_taxons.*, spree_products_taxons.position as position from spree_products_taxons
28
+ # ORDER BY position ASC
29
+ # ) I2 ON spree_products.id = I2.product_id
30
+ # WHERE spree_products.deleted_at IS NULL AND I2.taxon_id = #{id} AND
31
+ # (spree_variants.meta -> 'flow_data' -> 'exp' ->> '#{flow_experience_key}' IS NOT NULL)
32
+ # SQL
33
+ #
34
+ # Spree::Product.find_by_sql(query)
35
+ # end
36
+ # end
37
+ # end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ Zone.class_eval do
5
+ store_accessor :meta, :flow_data
6
+ end
7
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Zones
5
+ module FlowIoProductZoneDecorator
6
+ def self.prepended(base)
7
+ base.after_update :update_on_flow, if: -> { flow_data&.[]('key').present? }
8
+ base.before_destroy :remove_on_flow_io, if: -> { flow_data&.[]('key').present? }
9
+ end
10
+
11
+ def available_currencies
12
+ ((currencies || []) + [flow_data&.[]('currency')]).compact.uniq.reject(&:empty?)
13
+ end
14
+
15
+ def flow_io_experience
16
+ flow_data&.[]('key')
17
+ end
18
+
19
+ def flow_io_experience_currency
20
+ flow_data&.[]('currency')
21
+ end
22
+
23
+ def flow_io_active_experience?
24
+ flow_data&.[]('key').present? && flow_data['status'] == 'active'
25
+ end
26
+
27
+ def update_on_flow; end
28
+
29
+ def remove_on_flow_io
30
+ client = FlowcommerceSpree.client
31
+ client.experiences.delete_by_key(FlowcommerceSpree::ORGANIZATION, flow_data['key'])
32
+
33
+ # Flowcommerce `delete_by_key` methods are always returning `nil`, that's why this hack of fetching
34
+ # @http_handler from client. This handler is a LoggingHttpHandler, which got the http_client attr_reader
35
+ # implemented specifically for this purpose.
36
+ false if client.instance_variable_get(:@http_handler).http_client.error
37
+ end
38
+
39
+ def store_flow_io_data(received_experience, logger: FlowcommerceSpree.logger)
40
+ self.flow_data = received_experience.is_a?(Hash) ? received_experience : received_experience.to_hash
41
+ self.status = flow_data['status']
42
+
43
+ if new_record? && update_attributes(meta: meta, status: status, kind: 'country')
44
+ logger.info "\nNew flow.io experience imported as product zone: #{name}"
45
+ elsif update_columns(meta: meta.to_json, status: status, kind: 'country')
46
+ logger.info "\nProduct zone `#{name}` has been updated from flow.io"
47
+ end
48
+
49
+ self
50
+ end
51
+
52
+ Spree::Zones::Product.prepend(self) if Spree::Zones::Product.included_modules.exclude?(self)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowcommerceSpree
4
+ # A service object to import the data for product variants belonging to a flow.io Experience
5
+ class ImportExperienceItems
6
+ def self.run(zone, client: FlowcommerceSpree.client, organization: ORGANIZATION)
7
+ new(zone, client: client, organization: organization).run
8
+ end
9
+
10
+ def run
11
+ page_size = 100
12
+ offset = 0
13
+ items = []
14
+ total = 0
15
+
16
+ while offset == 0 || items.length == 100
17
+ # show current list size
18
+ @logger.info "\nGetting items: #{@experience_key.green}, rows #{offset} - #{offset + page_size}"
19
+
20
+ begin
21
+ items = @client.experiences
22
+ .get_items(@organization, experience: @experience_key, limit: page_size, offset: offset)
23
+ rescue Io::Flow::V0::HttpClient::PreconditionException => e
24
+ @logger.info "flow.io API error: #{e.message}"
25
+ break
26
+ end
27
+
28
+ offset += page_size
29
+ log_str = +''
30
+
31
+ items.each do |item|
32
+ total += 1
33
+ item_hash = item.to_hash
34
+ next unless (variant = Spree::Variant.find_by(sku: item_hash.delete(:number)))
35
+
36
+ status_in_experience = item_hash.dig(:local, :status)
37
+
38
+ if status_in_experience != 'included'
39
+ log_str << "[#{status_in_experience.red}]:"
40
+ else # If at least a variant is included in experience, include the product too
41
+ adjust_product_zone(variant)
42
+ end
43
+
44
+ variant.flow_import_item(item_hash, experience_key: @experience_key)
45
+
46
+ log_str << "#{variant.sku}, "
47
+ end
48
+ @logger.info log_str
49
+ end
50
+
51
+ @logger.info "\nData for #{total.to_s.green} products was imported."
52
+ end
53
+
54
+ private
55
+
56
+ def initialize(zone, client:, organization:)
57
+ @client = client
58
+ @experience_key = zone.flow_io_experience
59
+ @logger = client.instance_variable_get(:@http_handler).logger
60
+ @organization = organization
61
+ @zone = zone
62
+ end
63
+
64
+ def adjust_product_zone(variant)
65
+ return unless (product = variant.product)
66
+
67
+ zone_ids = product.zone_ids || []
68
+ zone_id_string = @zone.id.to_s
69
+ return if zone_ids.include?(zone_id_string)
70
+
71
+ zone_ids << zone_id_string
72
+ product.zone_ids = zone_ids
73
+ product.update_columns(meta: product.meta.to_json)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowcommerceSpree
4
+ # A service object to import the data for of flow.io Experience into Spree::Zones::Product
5
+ class ImportExperiences
6
+ def self.run(client: FlowcommerceSpree.client, organization: ORGANIZATION, with_items: nil, refresher: nil)
7
+ new(client: client, organization: organization, with_items: with_items, refresher: refresher).run
8
+ end
9
+
10
+ def run
11
+ # we have to log start, so that another process does not start while this one is running
12
+ @refresher.log_refresh!
13
+
14
+ @client.experiences.get(@organization, status: 'active').each do |experience|
15
+ experience_key = experience.key
16
+ zone = Spree::Zones::Product.find_or_initialize_by(name: experience_key.titleize)
17
+ zone.store_flow_io_data(experience, logger: @refresher.logger)
18
+
19
+ next @refresher.logger.info "Error: storing flow.io experience #{experience_key}" if zone.errors.any?
20
+
21
+ ImportExperienceItems.run(zone, client: @client) if @with_items
22
+ end
23
+
24
+ # Log sync end time
25
+ @refresher.log_refresh!(has_ended: true)
26
+ end
27
+
28
+ private
29
+
30
+ def initialize(client:, organization:, with_items: nil, refresher: Refresher.new)
31
+ @refresher = refresher
32
+ @client = client
33
+ @organization = organization
34
+ @with_items = with_items
35
+ end
36
+ end
37
+ end