flowcommerce_spree 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +91 -0
- data/Rakefile +33 -0
- data/SPREE_FLOW.md +134 -0
- data/app/assets/javascripts/flowcommerce_spree/application.js +13 -0
- data/app/assets/stylesheets/flowcommerce_spree/application.css +15 -0
- data/app/controllers/concerns/current_zone_loader_decorator.rb +49 -0
- data/app/controllers/flowcommerce_spree/webhooks_controller.rb +25 -0
- data/app/helpers/flowcommerce_spree/application_helper.rb +6 -0
- data/app/helpers/spree/admin/orders_helper_decorator.rb +17 -0
- data/app/helpers/spree/core/controller_helpers/flow_io_order_helper_decorator.rb +53 -0
- data/app/mailers/spree/spree_order_mailer_decorator.rb +24 -0
- data/app/models/flowcommerce_spree/settings.rb +8 -0
- data/app/models/spree/credit_card_decorator.rb +9 -0
- data/app/models/spree/flow_io_product_decorator.rb +91 -0
- data/app/models/spree/flow_io_variant_decorator.rb +205 -0
- data/app/models/spree/gateway/spree_flow_gateway.rb +116 -0
- data/app/models/spree/line_item_decorator.rb +15 -0
- data/app/models/spree/order_decorator.rb +179 -0
- data/app/models/spree/promotion_decorator.rb +10 -0
- data/app/models/spree/promotion_handler/coupon_decorator.rb +30 -0
- data/app/models/spree/spree_user_decorator.rb +15 -0
- data/app/models/spree/taxon_decorator.rb +37 -0
- data/app/models/spree/zone_decorator.rb +7 -0
- data/app/models/spree/zones/flow_io_product_zone_decorator.rb +55 -0
- data/app/services/flowcommerce_spree/import_experience_items.rb +76 -0
- data/app/services/flowcommerce_spree/import_experiences.rb +37 -0
- data/app/services/flowcommerce_spree/order_sync.rb +231 -0
- data/app/views/layouts/flowcommerce_spree/application.html.erb +14 -0
- data/app/views/spree/admin/payments/index.html.erb +28 -0
- data/app/views/spree/admin/promotions/edit.html.erb +57 -0
- data/app/views/spree/admin/shared/_order_summary.html.erb +44 -0
- data/app/views/spree/admin/shared/_order_summary_flow.html.erb +13 -0
- data/app/views/spree/order_mailer/confirm_email.html.erb +86 -0
- data/app/views/spree/order_mailer/confirm_email.text.erb +38 -0
- data/config/initializers/flowcommerce_spree.rb +7 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20201021160159_add_type_and_meta_to_spree_zone.rb +23 -0
- data/db/migrate/20201021755957_add_meta_to_spree_tables.rb +17 -0
- data/db/migrate/20201022173210_add_zone_type_to_spree_zone_members.rb +24 -0
- data/db/migrate/20201022174252_add_kind_to_zone.rb +22 -0
- data/lib/flow/error.rb +73 -0
- data/lib/flow/pay_pal.rb +25 -0
- data/lib/flow/simple_gateway.rb +115 -0
- data/lib/flowcommerce_spree.rb +31 -0
- data/lib/flowcommerce_spree/api.rb +48 -0
- data/lib/flowcommerce_spree/engine.rb +27 -0
- data/lib/flowcommerce_spree/experience_service.rb +65 -0
- data/lib/flowcommerce_spree/logging_http_client.rb +43 -0
- data/lib/flowcommerce_spree/logging_http_handler.rb +15 -0
- data/lib/flowcommerce_spree/refresher.rb +81 -0
- data/lib/flowcommerce_spree/session.rb +71 -0
- data/lib/flowcommerce_spree/version.rb +5 -0
- data/lib/flowcommerce_spree/webhook_service.rb +98 -0
- data/lib/simple_csv_writer.rb +44 -0
- data/lib/tasks/flowcommerce_spree.rake +289 -0
- 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,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
|