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