flowcommerce-solidus 0.1.11
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.version +1 -0
- data/bin/flowcommerce-solidus +121 -0
- data/lib/flowcommerce-solidus.rb +7 -0
- data/static/app/flow/README.md +77 -0
- data/static/app/flow/SOLIDUS_FLOW.md +127 -0
- data/static/app/flow/decorators/admin_decorators.rb +34 -0
- data/static/app/flow/decorators/localized_coupon_code_decorator.rb +49 -0
- data/static/app/flow/decorators/spree_credit_card_decorator.rb +35 -0
- data/static/app/flow/decorators/spree_order_decorator.rb +128 -0
- data/static/app/flow/decorators/spree_product_decorator.rb +16 -0
- data/static/app/flow/decorators/spree_user_decorator.rb +16 -0
- data/static/app/flow/decorators/spree_variant_decorator.rb +124 -0
- data/static/app/flow/flow.rb +67 -0
- data/static/app/flow/flow/error.rb +46 -0
- data/static/app/flow/flow/experience.rb +51 -0
- data/static/app/flow/flow/order.rb +267 -0
- data/static/app/flow/flow/pay_pal.rb +27 -0
- data/static/app/flow/flow/session.rb +77 -0
- data/static/app/flow/flow/simple_crypt.rb +30 -0
- data/static/app/flow/flow/simple_gateway.rb +123 -0
- data/static/app/flow/flow/webhook.rb +62 -0
- data/static/app/flow/lib/flow_api_refresh.rb +89 -0
- data/static/app/flow/lib/spree_flow_gateway.rb +86 -0
- data/static/app/flow/lib/spree_stripe_gateway.rb +142 -0
- data/static/app/views/spree/admin/payments/index.html.erb +37 -0
- data/static/app/views/spree/admin/promotions/edit.html.erb +59 -0
- data/static/app/views/spree/admin/shared/_order_summary.html.erb +58 -0
- data/static/app/views/spree/admin/shared/_order_summary_flow.html.erb +13 -0
- data/static/app/views/spree/order_mailer/confirm_email.html.erb +85 -0
- data/static/app/views/spree/order_mailer/confirm_email.text.erb +41 -0
- data/static/lib/tasks/flow.rake +248 -0
- metadata +160 -0
@@ -0,0 +1,128 @@
|
|
1
|
+
# added flow specific methods to Spree::Order
|
2
|
+
# http://docs.solidus.io/Spree/Order.html
|
3
|
+
|
4
|
+
require 'digest/sha1'
|
5
|
+
|
6
|
+
Spree::Order.class_eval do
|
7
|
+
|
8
|
+
# we now use Solidus number as Flow number, but I will this here for now
|
9
|
+
def flow_number
|
10
|
+
return self[:flow_number] unless self[:flow_number].blank?
|
11
|
+
return unless id
|
12
|
+
number
|
13
|
+
end
|
14
|
+
|
15
|
+
def flow_order
|
16
|
+
return nil unless flow_data['order']
|
17
|
+
Hashie::Mash.new flow_data['order']
|
18
|
+
end
|
19
|
+
|
20
|
+
# accepts line item, usually called from views
|
21
|
+
def flow_line_item_price line_item, total=false
|
22
|
+
result = unless flow_order
|
23
|
+
Flow.format_default_price(line_item.price * (total ? line_item.quantity : 1))
|
24
|
+
else
|
25
|
+
id = line_item.variant.id.to_s
|
26
|
+
|
27
|
+
lines = flow_order.lines || []
|
28
|
+
item = lines.select{ |el| el['item_number'] == id }.first
|
29
|
+
|
30
|
+
return Flow.price_not_found unless item
|
31
|
+
|
32
|
+
total ? item['total']['label'] : item['price']['label']
|
33
|
+
end
|
34
|
+
|
35
|
+
# add line item promo
|
36
|
+
# promo_total, adjustment_total
|
37
|
+
result += ' (%s)' % Flow.format_default_price(line_item.promo_total) if line_item.promo_total > 0
|
38
|
+
|
39
|
+
result
|
40
|
+
end
|
41
|
+
|
42
|
+
# prepares array of prices that can be easily renderd in templates
|
43
|
+
def flow_cart_breakdown
|
44
|
+
prices = []
|
45
|
+
|
46
|
+
price_model = Struct.new(:name, :label)
|
47
|
+
|
48
|
+
if flow_order
|
49
|
+
# duty, vat, ...
|
50
|
+
unless flow_order.prices
|
51
|
+
message = Flow::Error.format_message flow_order
|
52
|
+
raise Flow::Error.new(message)
|
53
|
+
end
|
54
|
+
|
55
|
+
flow_order.prices.each do |price|
|
56
|
+
prices.push price_model.new(price['key'].to_s.capitalize , price['label'])
|
57
|
+
end
|
58
|
+
else
|
59
|
+
price_elements = [:item_total, :adjustment_total, :included_tax_total, :additional_tax_total, :tax_total, :shipment_total, :promo_total]
|
60
|
+
price_elements.each do |el|
|
61
|
+
price = send(el)
|
62
|
+
if price > 0
|
63
|
+
label = Flow.format_default_price price
|
64
|
+
prices.push price_model.new(el.to_s.humanize.capitalize, label)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# discount is applied and we allways show it in default currency
|
69
|
+
if adjustment_total != 0
|
70
|
+
formated_discounted_price = Flow.format_default_price adjustment_total
|
71
|
+
prices.push price_model.new('Discount', formated_discounted_price)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# total
|
76
|
+
prices.push price_model.new(Spree.t(:total), flow_total)
|
77
|
+
|
78
|
+
prices
|
79
|
+
end
|
80
|
+
|
81
|
+
# shows localized total, if possible. if not, fall back to Solidus default
|
82
|
+
def flow_total
|
83
|
+
# r flow_order.total.label
|
84
|
+
price = flow_order.total.label if flow_order && flow_order.total
|
85
|
+
price || Flow.format_default_price(total)
|
86
|
+
end
|
87
|
+
|
88
|
+
def flow_experience
|
89
|
+
model = Struct.new(:key)
|
90
|
+
model.new flow_order.experience.key
|
91
|
+
rescue
|
92
|
+
model.new ENV.fetch('FLOW_BASE_COUNTRY')
|
93
|
+
end
|
94
|
+
|
95
|
+
# clear invalid zero amount payments. Solidsus bug?
|
96
|
+
def clear_zero_amount_payments!
|
97
|
+
# class attribute that can be set to true
|
98
|
+
return unless Flow::Order.clear_zero_amount_payments
|
99
|
+
|
100
|
+
payments.where(amount:0, state: ['invalid', 'processing', 'pending']).map(&:destroy)
|
101
|
+
end
|
102
|
+
|
103
|
+
def flow_order_authorized?
|
104
|
+
flow_data && flow_data['authorization'] ? true : false
|
105
|
+
end
|
106
|
+
|
107
|
+
def flow_order_captured?
|
108
|
+
flow_data['capture'] ? true : false
|
109
|
+
end
|
110
|
+
|
111
|
+
# completes order and sets all states to finalized and complete
|
112
|
+
# used when we have confirmed capture from Flow API or PayPal
|
113
|
+
def flow_finalize!
|
114
|
+
finalize! unless state == 'complete'
|
115
|
+
update_column :payment_state, 'paid' if payment_state != 'paid'
|
116
|
+
update_column :state, 'complete' if state != 'complete'
|
117
|
+
end
|
118
|
+
|
119
|
+
def flow_paymeny_method
|
120
|
+
if flow_data['payment_type'] == 'paypal'
|
121
|
+
'paypal'
|
122
|
+
else
|
123
|
+
'cc' # creait card is default
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
128
|
+
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# added flow specific methods to Spree::Variant
|
2
|
+
|
3
|
+
Spree::Product.class_eval do
|
4
|
+
|
5
|
+
# returns price tied to local experience from master variant
|
6
|
+
def flow_local_price(flow_exp)
|
7
|
+
variants.first.flow_local_price(flow_exp)
|
8
|
+
end
|
9
|
+
|
10
|
+
def flow_included?(flow_exp)
|
11
|
+
return true unless flow_exp
|
12
|
+
flow_data['%s.excluded' % flow_exp.key].to_i == 1 ? false : true
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# added flow specific methods to Spree::User
|
2
|
+
# which is for spree / solidus in same time
|
3
|
+
# - user object (for admins as well)
|
4
|
+
# - customer object
|
5
|
+
|
6
|
+
Spree::User.class_eval do
|
7
|
+
|
8
|
+
def flow_number
|
9
|
+
return unless id
|
10
|
+
|
11
|
+
token = ENV.fetch('SECRET_TOKEN')
|
12
|
+
'su-%s' % Digest::SHA1.hexdigest('%d-%s' % [id, token])
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# added flow specific methods to Spree::Variant
|
2
|
+
# solidus / spree save all the prices inside Variant object
|
3
|
+
# we choose to have cache jsonb field named flow_data that will hold all important
|
4
|
+
# flow sync data for specific
|
5
|
+
|
6
|
+
Spree::Variant.class_eval do
|
7
|
+
|
8
|
+
# after every save we sync product
|
9
|
+
# we generate sh1 checksums to update only when change happend
|
10
|
+
after_save :flow_sync_product
|
11
|
+
|
12
|
+
# clears flow cache from all records
|
13
|
+
def self.flow_truncate
|
14
|
+
all_records = all
|
15
|
+
all_records.each { |o| o.update_column :flow_data, {} }
|
16
|
+
puts 'Truncated %d records' % all_records.length
|
17
|
+
end
|
18
|
+
|
19
|
+
###
|
20
|
+
|
21
|
+
# syncs product variant with flow
|
22
|
+
def flow_sync_product
|
23
|
+
# initial Solidus seed will fail, so skip unless we have Flow data folder
|
24
|
+
return unless respond_to?(:flow_data)
|
25
|
+
|
26
|
+
flow_item = flow_api_item
|
27
|
+
flow_item_sh1 = Digest::SHA1.hexdigest flow_api_item.to_json
|
28
|
+
|
29
|
+
# skip if sync not needed
|
30
|
+
return nil if flow_data['last_sync_sh1'] == flow_item_sh1
|
31
|
+
|
32
|
+
response = FlowCommerce.instance.items.put_by_number(Flow.organization, id.to_s, flow_item)
|
33
|
+
|
34
|
+
# after successful put, write cache
|
35
|
+
update_column :flow_data, flow_data.merge('last_sync_sh1'=>flow_item_sh1)
|
36
|
+
|
37
|
+
response
|
38
|
+
end
|
39
|
+
|
40
|
+
###
|
41
|
+
|
42
|
+
def flow_spree_price
|
43
|
+
'%s %s' % [self.price, self.cost_currency]
|
44
|
+
end
|
45
|
+
|
46
|
+
def flow_prices flow_exp
|
47
|
+
flow_data.dig('exp', flow_exp.key, 'prices') || []
|
48
|
+
end
|
49
|
+
|
50
|
+
# returns price tied to local experience
|
51
|
+
def flow_local_price flow_exp
|
52
|
+
price = flow_prices(flow_exp).first
|
53
|
+
|
54
|
+
if flow_exp && price
|
55
|
+
price['label']
|
56
|
+
else
|
57
|
+
flow_spree_price
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# creates object for flow api
|
62
|
+
# TODO: Remove and use the one in rakefile
|
63
|
+
def flow_api_item
|
64
|
+
image_base = 'http://cdn.color-mont.com'
|
65
|
+
|
66
|
+
# add product categories
|
67
|
+
categories = []
|
68
|
+
taxon = product.taxons.first
|
69
|
+
while taxon
|
70
|
+
categories.unshift taxon.name
|
71
|
+
taxon = taxon.parent
|
72
|
+
end
|
73
|
+
|
74
|
+
images = product.images.first ? [
|
75
|
+
{ url: image_base + product.display_image.attachment(:large), tags: ['main'] },
|
76
|
+
{ url: image_base + product.images.first.attachment.url(:product), tags: ['thumbnail'] }
|
77
|
+
] : []
|
78
|
+
|
79
|
+
Io::Flow::V0::Models::ItemForm.new(
|
80
|
+
number: id.to_s,
|
81
|
+
locale: 'en_US',
|
82
|
+
language: 'en',
|
83
|
+
name: product.name,
|
84
|
+
description: product.description,
|
85
|
+
currency: cost_currency,
|
86
|
+
price: price.to_f,
|
87
|
+
images: images,
|
88
|
+
categories: categories,
|
89
|
+
attributes: {
|
90
|
+
weight: weight.to_s,
|
91
|
+
height: height.to_s,
|
92
|
+
width: width.to_s,
|
93
|
+
depth: depth.to_s,
|
94
|
+
is_master: is_master ? 'true' : 'false',
|
95
|
+
product_id: product_id.to_s,
|
96
|
+
tax_category: product.tax_category_id.to_s,
|
97
|
+
product_description: product.description,
|
98
|
+
product_shipping_category: product.shipping_category_id ? shipping_category.name : nil,
|
99
|
+
product_meta_title: product.meta_title.to_s,
|
100
|
+
product_meta_description: product.meta_description.to_s,
|
101
|
+
product_meta_keywords: product.meta_keywords.to_s,
|
102
|
+
product_slug: product.slug,
|
103
|
+
}.select{ |k,v| v.present? }
|
104
|
+
)
|
105
|
+
end
|
106
|
+
|
107
|
+
# gets flow catalog item, and imports it
|
108
|
+
# called from flow:sync_localized_items rake task
|
109
|
+
def flow_import_item(item)
|
110
|
+
experience_key = item.local.experience.key
|
111
|
+
flow_data['exp'] ||= {}
|
112
|
+
flow_data['exp'][experience_key] = {}
|
113
|
+
flow_data['exp'][experience_key]['status'] = item.local.status.value
|
114
|
+
flow_data['exp'][experience_key]['prices'] = item.local.prices.map do |price|
|
115
|
+
price = price.to_hash
|
116
|
+
[:includes, :adjustment].each { |el| price.delete(el) unless price[el] }
|
117
|
+
price
|
118
|
+
end
|
119
|
+
|
120
|
+
update_column :flow_data, flow_data.dup
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
124
|
+
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# module for communication and customization based on flow api
|
2
|
+
# for now all in same class
|
3
|
+
|
4
|
+
require 'logger'
|
5
|
+
|
6
|
+
module Flow
|
7
|
+
extend self
|
8
|
+
|
9
|
+
def organization() ENV.fetch('FLOW_ORGANIZATION') end
|
10
|
+
def base_country() ENV.fetch('FLOW_BASE_COUNTRY') end
|
11
|
+
def api_key() ENV.fetch('FLOW_API_KEY') end
|
12
|
+
|
13
|
+
# builds curl command and gets remote data
|
14
|
+
def api action, path, params={}, body=nil
|
15
|
+
body ||= params.delete(:BODY)
|
16
|
+
|
17
|
+
remote_params = URI.encode_www_form params
|
18
|
+
remote_path = debug_path = path.sub('%o', Flow.organization).sub(':organization', Flow.organization)
|
19
|
+
remote_path += '?%s' % remote_params unless remote_params.blank?
|
20
|
+
|
21
|
+
curl = ['curl -s']
|
22
|
+
curl.push '-X %s' % action.to_s.upcase
|
23
|
+
curl.push '-u %s:' % api_key
|
24
|
+
|
25
|
+
if body
|
26
|
+
body = body.to_json unless body.is_a?(Array)
|
27
|
+
curl.push '-H "Content-Type: application/json"'
|
28
|
+
curl.push "-d '%s'" % body.gsub(%['], %['"'"']) if body
|
29
|
+
end
|
30
|
+
|
31
|
+
curl.push '"https://api.flow.io%s"' % remote_path
|
32
|
+
command = curl.join(' ')
|
33
|
+
|
34
|
+
puts command if defined?(Rails::Console)
|
35
|
+
|
36
|
+
dir = Rails.root.join('log/api')
|
37
|
+
Dir.mkdir(dir) unless Dir.exist?(dir)
|
38
|
+
debug_file = '%s/%s.bash' % [dir, debug_path.gsub(/[^\w]+/, '_')]
|
39
|
+
File.write debug_file, command + "\n"
|
40
|
+
|
41
|
+
data = JSON.load `#{command}`
|
42
|
+
|
43
|
+
if data.kind_of?(Hash) && data['code'] == 'generic_error'
|
44
|
+
ap data
|
45
|
+
data
|
46
|
+
else
|
47
|
+
data
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def log_api_error
|
52
|
+
|
53
|
+
end
|
54
|
+
|
55
|
+
def logger
|
56
|
+
@logger ||= Logger.new('./log/flow.log') # or nil for no logging
|
57
|
+
end
|
58
|
+
|
59
|
+
def price_not_found
|
60
|
+
'n/a'
|
61
|
+
end
|
62
|
+
|
63
|
+
def format_default_price amount
|
64
|
+
'$%.2f' % amount
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# Flow (2017)
|
2
|
+
# for flow errors
|
3
|
+
|
4
|
+
require 'digest/sha1'
|
5
|
+
|
6
|
+
class Flow::Error < StandardError
|
7
|
+
|
8
|
+
# logs error to file for easy discovery and fix
|
9
|
+
def self.log(exception, request)
|
10
|
+
history = exception.backtrace.reject{ |el| el.index('/gems/') }.map{ |el| el.sub(Rails.root.to_s, '') }.join($/)
|
11
|
+
|
12
|
+
msg = '%s in %s' % [exception.class, request.url]
|
13
|
+
data = [msg, exception.message, history].join("\n\n")
|
14
|
+
key = Digest::SHA1.hexdigest exception.backtrace.first.split(' ').first
|
15
|
+
|
16
|
+
folder = Rails.root.join('log/exceptions').to_s
|
17
|
+
Dir.mkdir(folder) unless Dir.exists?(folder)
|
18
|
+
|
19
|
+
folder += "/#{exception.class.to_s.tableize.gsub('/','-')}"
|
20
|
+
Dir.mkdir(folder) unless Dir.exists?(folder)
|
21
|
+
|
22
|
+
"#{folder}/#{key}.txt".tap do |path|
|
23
|
+
File.write(path, data)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.format_message order, flow_experience=nil
|
28
|
+
message = if order['messages']
|
29
|
+
msg = order['messages'].join(', ')
|
30
|
+
|
31
|
+
if order['numbers']
|
32
|
+
msg += ' (%s)' % Spree::Variant.where(id: order['numbers']).map(&:name).join(', ')
|
33
|
+
end
|
34
|
+
|
35
|
+
msg
|
36
|
+
else
|
37
|
+
'Order not properly localized (sync issue)'
|
38
|
+
end
|
39
|
+
|
40
|
+
sub_info = 'Flow.io'
|
41
|
+
sub_info += ' - %s' % flow_experience.key[0, 15] if flow_experience
|
42
|
+
|
43
|
+
'%s (%s)' % [message, sub_info]
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# Flow.io (2017)
|
2
|
+
# communicates with flow api, easy access
|
3
|
+
# to basic shop frontend and backend needs
|
4
|
+
|
5
|
+
module Flow::Experience
|
6
|
+
extend self
|
7
|
+
|
8
|
+
def all no_world=nil
|
9
|
+
experiences = get_from_flow
|
10
|
+
no_world ? experiences.select{ |exp| exp.key != 'world' } : experiences
|
11
|
+
end
|
12
|
+
|
13
|
+
def keys
|
14
|
+
all.map{ |el| el.key }
|
15
|
+
end
|
16
|
+
|
17
|
+
def get key
|
18
|
+
all.each do |exp|
|
19
|
+
return exp if exp.key == key
|
20
|
+
end
|
21
|
+
nil
|
22
|
+
end
|
23
|
+
|
24
|
+
def compact
|
25
|
+
all.map { |exp| [exp.country, exp.key, exp.name] }
|
26
|
+
end
|
27
|
+
|
28
|
+
def default
|
29
|
+
Flow::Experience.all.select{ |exp| exp.key == 'united-states-of-america' }.first
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def get_from_flow
|
35
|
+
return cached_experinces if cache_valid?
|
36
|
+
|
37
|
+
experiences = FlowCommerce.instance.experiences.get(Flow.organization)
|
38
|
+
@cache = [experiences, Time.now]
|
39
|
+
experiences
|
40
|
+
end
|
41
|
+
|
42
|
+
def cache_valid?
|
43
|
+
# cache experinces in worker memory for 1 minute
|
44
|
+
@cache && @cache[1] > Time.now.ago(1.minute)
|
45
|
+
end
|
46
|
+
|
47
|
+
def cached_experinces
|
48
|
+
@cache[0]
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|