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