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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowcommerceSpree
4
+ class Engine < ::Rails::Engine
5
+ require 'spree/core'
6
+ isolate_namespace FlowcommerceSpree
7
+
8
+ config.before_initialize do
9
+ FlowcommerceSpree::Config = FlowcommerceSpree::Settings.new
10
+ end
11
+
12
+ config.after_initialize do
13
+ # init Flow payments as an option
14
+ # app.config.spree.payment_methods << Spree::Gateway::Flow
15
+
16
+ Flow::SimpleGateway.clear_zero_amount_payments = true
17
+ end
18
+
19
+ def self.activate
20
+ Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')).sort.each do |c|
21
+ Rails.configuration.cache_classes ? require(c) : load(c)
22
+ end
23
+ end
24
+
25
+ config.to_prepare(&method(:activate).to_proc)
26
+ end
27
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Communicates with flow.io API, easy access
4
+ # to basic shop frontend and backend needs
5
+ module FlowcommerceSpree
6
+ module ExperienceService
7
+ extend self
8
+
9
+ def all(no_world = nil)
10
+ experiences = fetch_from_flow
11
+ no_world ? experiences.reject { |exp| exp.key == 'world' } : experiences
12
+ end
13
+
14
+ def keys
15
+ all.map(&:key)
16
+ end
17
+
18
+ def get(key)
19
+ all.each do |exp|
20
+ return exp if exp.key == key
21
+ end
22
+ nil
23
+ end
24
+
25
+ def get_by_subcatalog_id(subcatalog_id)
26
+ fetch_from_flow.each do |experince|
27
+ return experince if experince.subcatalog.id == subcatalog_id
28
+ end
29
+
30
+ nil
31
+ end
32
+
33
+ def compact
34
+ all.map { |exp| [exp.country, exp.key, exp.name] }
35
+ end
36
+
37
+ def default
38
+ FlowcommerceSpree::ExperienceService
39
+ .all.select { |exp| exp.key.downcase == ENV.fetch('FLOW_BASE_COUNTRY').downcase }.first
40
+ end
41
+
42
+ private
43
+
44
+ def fetch_from_flow
45
+ # return cached_experinces if cache_valid?
46
+
47
+ experiences = FlowcommerceSpree.client.experiences.get ORGANIZATION
48
+
49
+ # work with active axperiences only
50
+ # experiences = experiences.select { |it| it.status.value == 'active' }
51
+
52
+ # @cache = [experiences, Time.now]
53
+ experiences
54
+ end
55
+
56
+ def cache_valid?
57
+ # cache experinces in worker memory for 1 minute
58
+ @cache && @cache[1] > Time.now.ago(1.minute)
59
+ end
60
+
61
+ def cached_experinces
62
+ @cache[0]
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowcommerceSpree
4
+ class LoggingHttpClient < ::Io::Flow::V0::HttpClient::DefaultHttpHandlerInstance
5
+ attr_reader :error
6
+
7
+ def initialize(base_uri, logger: FlowcommerceSpree.logger)
8
+ super(base_uri)
9
+ @logger = logger
10
+ end
11
+
12
+ def execute(request)
13
+ # original_open = client.open_timeout
14
+ # original_read = client.read_timeout
15
+
16
+ start_time = Time.now.utc.round(10)
17
+ @logger.info "start #{request.method} #{request.path}"
18
+ @logger.info "body: #{request.instance_variable_get(:@header)}"
19
+ @logger.info "body: #{request.body}"
20
+
21
+ if request.path.start_with?('/organizations')
22
+ # Contrived example to show how client settings can be adjusted
23
+ # client.open_timeout = 60
24
+ # client.read_timeout = 60
25
+ end
26
+
27
+ begin
28
+ response = super
29
+ rescue Io::Flow::V0::HttpClient::ServerError => e
30
+ @error = { error: e }.to_json
31
+ ensure
32
+ # client.open_timeout = original_open
33
+ # client.read_timeout = original_read
34
+
35
+ end_time = Time.now.utc.round(10)
36
+ duration = ((end_time - start_time) * 1000).round(0)
37
+ @logger.info "complete #{request.method} #{request.path} #{duration} ms"
38
+ @logger.info "response: #{response}"
39
+ @logger.info "Error: #{e.inspect}" if e
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowcommerceSpree
4
+ class LoggingHttpHandler < ::Io::Flow::V0::HttpClient::DefaultHttpHandler
5
+ attr_reader :http_client, :logger
6
+
7
+ def initialize(logger: FlowcommerceSpree.logger)
8
+ @logger = logger
9
+ end
10
+
11
+ def instance(base_uri, _path)
12
+ @http_client = LoggingHttpClient.new(base_uri, logger: @logger)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ # Service class to manage product sync scheduling
6
+ module FlowcommerceSpree
7
+ class Refresher
8
+ SYNC_INTERVAL_IN_MINUTES = 60 unless defined?(SYNC_INTERVAL_IN_MINUTES)
9
+
10
+ attr_reader :logger
11
+
12
+ def initialize(logger: FlowcommerceSpree.logger)
13
+ @logger = logger
14
+ end
15
+
16
+ def data
17
+ @data ||= FlowcommerceSpree::Config.product_catalog_upload || {}
18
+ end
19
+
20
+ def duration
21
+ return '? (unknown)' if !data[:start] || !data[:end] || data[:start] > data[:end]
22
+
23
+ (data[:end] - data[:start]) / 60
24
+ end
25
+
26
+ def write
27
+ yield data
28
+ FlowcommerceSpree::Config.product_catalog_upload = data
29
+ @data = nil
30
+ end
31
+
32
+ def schedule_refresh!
33
+ write do |data|
34
+ data[:force_refresh] = true
35
+ end
36
+ end
37
+
38
+ def needs_refresh?
39
+ return false if in_progress?
40
+
41
+ now = Time.zone.now.to_i
42
+ data[:end] ||= now - 10_000
43
+
44
+ # needs refresh if last refresh started more than threshold ago
45
+ if data[:end] < (now - (60 * SYNC_INTERVAL_IN_MINUTES))
46
+ logger.info 'Last refresh ended long time ago, needs refresh.'
47
+ true
48
+ elsif data[:force_refresh]
49
+ logger.info 'Force refresh scheduled, refreshing.'
50
+ true
51
+ else
52
+ logger.info format('No need for refresh, ended before %<now>d seconds.', (now - data[:end]))
53
+ @data = nil
54
+ false
55
+ end
56
+ end
57
+
58
+ def in_progress?
59
+ # This method needs fresh data, that's why not using the memoized `data` method
60
+ @data = FlowcommerceSpree::Config.product_catalog_upload || {}
61
+ return false unless data[:in_progress]
62
+
63
+ logger.info 'Could not be run, another refresh is still in progress, quitting'
64
+ end
65
+
66
+ # for start just call log_refresh! and end it with has_ended: true statement
67
+ def log_refresh!(has_ended: false)
68
+ data.delete(:force_refresh)
69
+
70
+ write do |data|
71
+ if has_ended
72
+ data[:end] = Time.zone.now.to_i
73
+ data.delete(:in_progress)
74
+ else
75
+ data[:in_progress] = true
76
+ data[:start] = Time.zone.now.to_i
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ # communicates with flow.io api, easy access to session
4
+ module FlowcommerceSpree
5
+ class Session
6
+ attr_accessor :session, :localized, :visitor
7
+
8
+ def initialize(ip:, visitor:)
9
+ ip = '127.0.0.1' if ip == '::1'
10
+
11
+ @ip = ip
12
+ @visitor = visitor
13
+ end
14
+
15
+ # create session with blank data
16
+ def create
17
+ data = { ip: @ip,
18
+ visit: { id: @visitor,
19
+ expires_at: (Time.now + 30.minutes).iso8601 } }
20
+
21
+ session_model = ::Io::Flow::V0::Models::SessionForm.new data
22
+ @session = FlowCommerce.instance(http_handler: LoggingHttpHandler.new)
23
+ .sessions.post_organizations_by_organization(ORGANIZATION, session_model)
24
+ end
25
+
26
+ # if we want to manualy switch to specific country or experience
27
+ def update(data)
28
+ @session = FlowCommerce.instance.sessions.put_by_session(@session.id,
29
+ ::Io::Flow::V0::Models::SessionPutForm.new(data))
30
+ end
31
+
32
+ # get local experience or return nil
33
+ def experience
34
+ @session.local&.experience
35
+ end
36
+
37
+ def local
38
+ @session.local
39
+ end
40
+
41
+ def id
42
+ @session.id
43
+ end
44
+
45
+ def localized?
46
+ # use flow if we are not in default country
47
+ return false unless local
48
+ return false if @localized.class == FalseClass
49
+
50
+ local.country.iso_3166_3 != ENV.fetch('FLOW_BASE_COUNTRY').upcase
51
+ end
52
+
53
+ # because we do not get full experience from session, we have to get from exp list
54
+ def delivered_duty_options
55
+ return nil unless experience
56
+
57
+ return unless (flow_experience = Flow::Experience.get(experience.key))
58
+
59
+ Hashie::Mash.new(flow_experience.settings.delivered_duty.to_hash)
60
+ end
61
+
62
+ # if we have more than one choice, we show choice popup
63
+ def offers_delivered_duty_choice?
64
+ if (options = delivered_duty_options)
65
+ options.available.length > 1
66
+ else
67
+ false
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowcommerceSpree
4
+ VERSION = '0.0.1'
5
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowcommerceSpree
4
+ # communicates with flow api, responds to webhook events
5
+ class WebhookService
6
+ attr_accessor :errors, :product, :variant
7
+ alias full_messages errors
8
+
9
+ def self.process(data, opts = {})
10
+ new(data, opts).process
11
+ end
12
+
13
+ def initialize(data, opts = {})
14
+ @data = data
15
+ @opts = opts
16
+ @errors = []
17
+ end
18
+
19
+ def process
20
+ org = @data['organization']
21
+ if org != ORGANIZATION
22
+ errors << { message: "Organization name mismatch for #{org}" }
23
+ else
24
+ discriminator = @data['discriminator']
25
+ hook_method = "hook_#{discriminator}"
26
+ # If hook processing method registered an error, a self.object of WebhookService with this error will be
27
+ # returned, else an ActiveRecord object will be returned
28
+ return __send__(hook_method) if respond_to?(hook_method, true)
29
+
30
+ errors << { message: "No hook for #{discriminator}" }
31
+ end
32
+
33
+ self
34
+ end
35
+
36
+ private
37
+
38
+ def hook_experience_upserted_v2
39
+ experience = @data['experience']
40
+ Spree::Zones::Product.find_or_initialize_by(name: experience['key'].titleize).store_flow_io_data(experience)
41
+ end
42
+
43
+ def hook_local_item_upserted
44
+ if (local_item = @data['local_item'])
45
+ if (received_sku = local_item.dig('item', 'number'))
46
+ if (@variant = Spree::Variant.find_by(sku: received_sku))
47
+ @variant.add_flow_io_experience_data(
48
+ local_item.dig('experience', 'key'),
49
+ 'prices' => [local_item.dig('pricing', 'price')], 'status' => local_item['status']
50
+ )
51
+
52
+ @variant.update_column(:meta, @variant.meta.to_json)
53
+ return @variant
54
+ else
55
+ errors << { message: "Variant with sku [#{received_sku}] not found!" }
56
+ end
57
+ else
58
+ errors << { message: 'SKU param missing' }
59
+ end
60
+ else
61
+ errors << { message: 'Local item param missing' }
62
+ end
63
+
64
+ self
65
+ end
66
+
67
+ def hook_order_upserted_v2
68
+ errors << { message: 'Order param missing' } unless (received_order = @data['order'])
69
+
70
+ if errors.none? && (order_number = received_order['number'])
71
+ if (order = Spree::Order.find_by(number: order_number))
72
+ order.flow_data['order'] = received_order.to_hash
73
+ attrs_to_update = { meta: order.meta.to_json }
74
+ if order.flow_data['order']['submitted_at'].present?
75
+ attrs_to_update[:state] = 'complete'
76
+ attrs_to_update[:completed_at] = Time.zone.now
77
+ end
78
+
79
+ order.update_columns(attrs_to_update)
80
+ return order
81
+ else
82
+ errors << { message: "Order #{order_number} not found" }
83
+ end
84
+ else
85
+ errors << { message: 'Order number param missing' }
86
+ end
87
+
88
+ self
89
+ end
90
+
91
+ # send en email when order is refunded
92
+ def hook_refund_upserted_v2
93
+ Spree::OrderMailer.refund_complete_email(@data).deliver
94
+
95
+ 'Email delivered'
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # simple class to build scv files
4
+
5
+ # csv = CsvWriter.new
6
+ # csv.add a: 1, b: 'a', c: '"a'
7
+ # csv.add a: ',', b: 'foo, bar'
8
+ # csv.to_s
9
+
10
+ class SimpleCsvWriter
11
+ def initialize(delimiter: nil)
12
+ @data = []
13
+ @delimiter = delimiter || "\t"
14
+ end
15
+
16
+ # add hash or list
17
+ def add(data)
18
+ list = if data.class == Hash
19
+ @keys ||= data.keys
20
+ @keys.map { |key| data[key] }
21
+ else
22
+ data
23
+ end
24
+
25
+ @data.push list.map { |el| fmt(el) }.join(@delimiter)
26
+ end
27
+
28
+ def to_s
29
+ if @keys
30
+ @keys.map(&:to_s).join(@delimiter) + "\n" +
31
+ @data.join($RS)
32
+ else
33
+ @data.join($RS)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def fmt(item)
40
+ item = item.to_s.gsub($RS, '\\n').gsub('"', '""')
41
+
42
+ item.include?(@delimiter) || item.include?('\\') ? "\"#{item}\"" : item
43
+ end
44
+ end