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