flowcommerce-solidus 0.1.11

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 (33) hide show
  1. checksums.yaml +7 -0
  2. data/.version +1 -0
  3. data/bin/flowcommerce-solidus +121 -0
  4. data/lib/flowcommerce-solidus.rb +7 -0
  5. data/static/app/flow/README.md +77 -0
  6. data/static/app/flow/SOLIDUS_FLOW.md +127 -0
  7. data/static/app/flow/decorators/admin_decorators.rb +34 -0
  8. data/static/app/flow/decorators/localized_coupon_code_decorator.rb +49 -0
  9. data/static/app/flow/decorators/spree_credit_card_decorator.rb +35 -0
  10. data/static/app/flow/decorators/spree_order_decorator.rb +128 -0
  11. data/static/app/flow/decorators/spree_product_decorator.rb +16 -0
  12. data/static/app/flow/decorators/spree_user_decorator.rb +16 -0
  13. data/static/app/flow/decorators/spree_variant_decorator.rb +124 -0
  14. data/static/app/flow/flow.rb +67 -0
  15. data/static/app/flow/flow/error.rb +46 -0
  16. data/static/app/flow/flow/experience.rb +51 -0
  17. data/static/app/flow/flow/order.rb +267 -0
  18. data/static/app/flow/flow/pay_pal.rb +27 -0
  19. data/static/app/flow/flow/session.rb +77 -0
  20. data/static/app/flow/flow/simple_crypt.rb +30 -0
  21. data/static/app/flow/flow/simple_gateway.rb +123 -0
  22. data/static/app/flow/flow/webhook.rb +62 -0
  23. data/static/app/flow/lib/flow_api_refresh.rb +89 -0
  24. data/static/app/flow/lib/spree_flow_gateway.rb +86 -0
  25. data/static/app/flow/lib/spree_stripe_gateway.rb +142 -0
  26. data/static/app/views/spree/admin/payments/index.html.erb +37 -0
  27. data/static/app/views/spree/admin/promotions/edit.html.erb +59 -0
  28. data/static/app/views/spree/admin/shared/_order_summary.html.erb +58 -0
  29. data/static/app/views/spree/admin/shared/_order_summary_flow.html.erb +13 -0
  30. data/static/app/views/spree/order_mailer/confirm_email.html.erb +85 -0
  31. data/static/app/views/spree/order_mailer/confirm_email.text.erb +41 -0
  32. data/static/lib/tasks/flow.rake +248 -0
  33. metadata +160 -0
@@ -0,0 +1,267 @@
1
+ # represents flow.io order
2
+ # for easy intgration we pass current
3
+ # - flow experirnce
4
+ # - solidus / spree order
5
+ # - current customer, presetnt as @current_spree_user controller instance variable
6
+ #
7
+ # example:
8
+ # flow_order = Flow::Order.new # init flow-order object
9
+ # order: Spree::Order.last,
10
+ # experience: @flow_session.experience
11
+ # customer: Spree::User.last
12
+ # fo.build_flow_request # builds json body to be posted to flow api
13
+ # fo.synchronize! # sends order to flow
14
+
15
+ class Flow::Order
16
+ FLOW_CENTER = 'default' unless defined?(::Flow::Order::FLOW_CENTER)
17
+
18
+ attr_reader :response
19
+ attr_reader :order
20
+ attr_reader :customer
21
+ attr_reader :body
22
+
23
+ class << self
24
+ def clear_cache order
25
+ return unless order.flow_data['order']
26
+ order.flow_data.delete('order')
27
+ order.update_column :flow_data, order.flow_data.dup
28
+ end
29
+ end
30
+
31
+ ###
32
+
33
+ def initialize order:, experience: nil, customer: nil
34
+ # when sending email, we do not have experience defined
35
+ unless experience
36
+ if order.flow_order
37
+ experience = Flow::Experience.get(order.flow_order['experience']['key'])
38
+ else
39
+ raise(ArgumentError, 'Experience not defined and not found in flow data')
40
+ end
41
+ end
42
+
43
+ @experience = experience
44
+ @order = order
45
+ @customer = customer
46
+ @items = []
47
+ end
48
+
49
+ # helper method to send complete order from spreee to flow
50
+ def synchronize!
51
+ sync_body!
52
+ check_state!
53
+ write_response_in_cache
54
+ @response
55
+ end
56
+
57
+ def error
58
+ @response['messages'].join(', ')
59
+ end
60
+
61
+ def error_code
62
+ @response['code']
63
+ end
64
+
65
+ def error?
66
+ @response && @response['code'] && @response['messages']
67
+ end
68
+
69
+ def delivery
70
+ deliveries.select{ |el| el[:active] }.first
71
+ end
72
+
73
+ # delivery methods are defined in flow console
74
+ def deliveries
75
+ # if we have erorr with an order, but still using this method
76
+ return [] unless @order.flow_order
77
+
78
+ @order.flow_data ||= {}
79
+
80
+ delivery_list = @order.flow_order['deliveries'][0]['options']
81
+ delivery_list = delivery_list.map do |opts|
82
+ name = opts['tier']['name']
83
+ name += ' (%s)' % opts['tier']['strategy'] if opts['tier']['strategy']
84
+ selection_id = opts['id']
85
+
86
+ {
87
+ id: selection_id,
88
+ price: { label: opts['price']['label'] },
89
+ active: @order.flow_order['selections'].include?(selection_id),
90
+ name: name
91
+ }
92
+ end.to_a
93
+
94
+ # make first one active unless we have active element
95
+ delivery_list.first[:active] = true unless delivery_list.select{ |el| el[:active] }.first
96
+
97
+ delivery_list
98
+ end
99
+
100
+ def total_price
101
+ @order.flow_total
102
+ end
103
+
104
+ def delivered_duty
105
+ # paid is default
106
+ @order.flow_data['delivered_duty'] || ::Io::Flow::V0::Models::DeliveredDuty.paid.value
107
+ end
108
+
109
+ private
110
+
111
+ # if customer is defined, add customer info
112
+ # it is possible to have order in solidus without customer info (new guest session)
113
+ def add_customer opts
114
+ return unless @customer
115
+
116
+ address = @customer.ship_address
117
+ # address = nil
118
+ if address
119
+ opts[:customer] = {
120
+ name: {
121
+ first: address.firstname,
122
+ last: address.lastname
123
+ },
124
+ email: @customer.email,
125
+ number: @customer.flow_number,
126
+ phone: address.phone
127
+ }
128
+
129
+ streets = []
130
+ streets.push address.address1 unless address.address1.blank?
131
+ streets.push address.address2 unless address.address2.blank?
132
+
133
+ opts[:destination] = {
134
+ streets: streets,
135
+ city: address.city,
136
+ province: address.state_name,
137
+ postal: address.zipcode,
138
+ country: (address.country.iso3 rescue 'USA'),
139
+ contact: opts[:customer]
140
+ }
141
+
142
+ opts[:destination].delete_if { |k,v| v.nil? }
143
+ end
144
+
145
+ opts
146
+ end
147
+
148
+ # builds object that can be sent to api.flow.io to sync order data
149
+ def build_flow_request
150
+ @order.line_items.each do |line_item|
151
+ add_item line_item
152
+ end
153
+
154
+ flow_number = @order.flow_number
155
+
156
+ opts = {}
157
+ opts[:organization] = Flow.organization
158
+ opts[:experience] = @experience.key
159
+ opts[:expand] = 'experience'
160
+
161
+ body = {}
162
+ body = {
163
+ items: @items,
164
+ number: flow_number
165
+ }
166
+
167
+ add_customer body if @customer
168
+
169
+ # if defined, add selection (delivery options) and delivered_duty from flow_data
170
+ body[:selections] = [@order.flow_data['selection']] if @order.flow_data['selection']
171
+ body[:delivered_duty] = @order.flow_data['delivered_duty'] if @order.flow_data['delivered_duty']
172
+
173
+ # discount on full order is applied
174
+ if @order.adjustment_total != 0
175
+ body[:discount] = {
176
+ amount: @order.adjustment_total,
177
+ currency: @order.currency
178
+ }
179
+ end
180
+
181
+ # calculate digest body and cache it
182
+ @digest = Digest::SHA1.hexdigest(opts.to_json + body.to_json)
183
+
184
+ [opts, body]
185
+ end
186
+
187
+ def sync_body!
188
+ opts, @body = build_flow_request
189
+
190
+ @use_get = false
191
+
192
+ # use get if order is completed and closed
193
+ @use_get = true if @order.state == 'complete'
194
+
195
+ # use get if local digest hash check said there is no change
196
+ @use_get ||= true if @order.flow_data['digest'] == @digest
197
+
198
+ # do not use get if there is no local order cache
199
+ @use_get = false unless @order.flow_data['order']
200
+
201
+ if @use_get
202
+ @response = Flow.api :get, '/:organization/orders/%s' % @body[:number], expand: 'experience'
203
+ else
204
+ # replace when fixed integer error
205
+ # @body[:items].map! { |item| ::Io::Flow::V0::Models::LineItemForm.new(item) }
206
+ # opts[:experience] = @experience.key
207
+ # order_put_form = ::Io::Flow::V0::Models::OrderPutForm.new(@body)
208
+ # r FlowCommerce.instance.orders.put_by_number(Flow.organization, @order.flow_number, order_put_form, opts)
209
+
210
+ @response = Flow.api :put, '/:organization/orders/%s' % @body[:number], opts, @body
211
+ end
212
+ end
213
+
214
+ def check_state!
215
+ # authorize if not authorized
216
+ # if !@order.flow_order_authorized?
217
+
218
+ # authorize payment on complete, unless authorized
219
+ if @order.state == 'complete' && !@order.flow_order_authorized?
220
+ simple_gateway = Flow::SimpleGateway.new(@order)
221
+ simple_gateway.cc_authorization
222
+ end
223
+
224
+ @order.flow_finalize! if @order.flow_order_authorized? && @order.state != 'complete'
225
+ end
226
+
227
+ def add_item line_item
228
+ variant = line_item.variant
229
+ price_root = variant.flow_data['exp'][@experience.key]['prices'][0] rescue {}
230
+
231
+ # create flow order line item
232
+ item = {
233
+ center: FLOW_CENTER,
234
+ number: variant.id.to_s,
235
+ quantity: line_item.quantity,
236
+ price: {
237
+ amount: price_root['amount'] || variant.cost_price,
238
+ currency: price_root['currency'] || variant.cost_currency
239
+ }
240
+ }
241
+
242
+ @items.push item
243
+ end
244
+
245
+ # set cache for total order ammount
246
+ # written in flow_data field inside spree_orders table
247
+ def write_response_in_cache
248
+ if !@response || error?
249
+ @order.flow_data.delete('digest')
250
+ @order.flow_data.delete('order')
251
+ else
252
+ response_total = @response.dig('total', 'label')
253
+ cache_total = @order.flow_data.dig('order', 'total', 'label')
254
+
255
+ # return if total is not changed, no products removed or added
256
+ return if @use_get && response_total == cache_total
257
+
258
+ # update local order
259
+ @order.flow_data['digest'] = @digest
260
+ @order.flow_data['order'] = @response.to_hash
261
+ end
262
+
263
+ @order.save
264
+ end
265
+
266
+ end
267
+
@@ -0,0 +1,27 @@
1
+ # Flow.io (2017)
2
+ # communicates with flow api, responds to webhook events
3
+
4
+ module Flow::PayPal
5
+ extend self
6
+
7
+ def get_id(order)
8
+ if order.flow_order
9
+ # get PayPal ID using Flow api
10
+ body = {
11
+ # discriminator: 'merchant_of_record_payment_form',
12
+ method: 'paypal',
13
+ order_number: order.number,
14
+ amount: order.flow_order.total.amount,
15
+ currency: order.flow_order.total.currency,
16
+ }
17
+
18
+ # Flow.api :post, '/:organization/payments', {}, body
19
+
20
+ form = ::Io::Flow::V0::Models::MerchantOfRecordPaymentForm.new body
21
+ FlowCommerce.instance.payments.post Flow.organization, form
22
+ else
23
+ # to do
24
+ raise 'PayPal only supported while using flow'
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,77 @@
1
+ # Flow.io (2017)
2
+ # communicates with flow api, easy access to session
3
+
4
+ class Flow::Session
5
+ attr_accessor :session, :localized, :visitor
6
+
7
+ def self.restore packed_session
8
+ Marshal.load packed_session
9
+ end
10
+
11
+ # flow session can ve created via IP or local cached OrganizationSession dump
12
+ # Flow::Experience.all.first.key
13
+ # Flow sessions need buest-guess visitor_id and
14
+ def initialize ip:, visitor:
15
+ ip = '127.0.0.1' if ip == '::1'
16
+
17
+ @ip = ip
18
+ @visitor = visitor
19
+ end
20
+
21
+ # create session with blank data
22
+ def create
23
+ data = {
24
+ ip: @ip,
25
+ visit: {
26
+ id: @visitor,
27
+ expires_at: (Time.now+30.minutes).iso8601
28
+ }
29
+ }
30
+
31
+ session_model = ::Io::Flow::V0::Models::SessionForm.new data
32
+ @session = FlowCommerce.instance.sessions.post_organizations_by_organization Flow.organization, session_model
33
+ end
34
+
35
+ # if we want to manualy switch to specific country or experience
36
+ def update data
37
+ @session = FlowCommerce.instance.sessions.put_by_session(
38
+ @session.id,
39
+ ::Io::Flow::V0::Models::SessionPutForm.new(data)
40
+ )
41
+ end
42
+
43
+ def dump
44
+ Marshal.dump self
45
+ end
46
+
47
+ # get local experience or return nil
48
+ def experience
49
+ @session.local ? @session.local.experience : Flow::Experience.default
50
+ end
51
+
52
+ def local
53
+ @session.local
54
+ end
55
+
56
+ def id
57
+ @session.id
58
+ end
59
+
60
+ def localized?
61
+ # use flow if we are not in default country
62
+ return false unless local
63
+ return false if @localized.class == FalseClass
64
+ local.country.iso_3166_3 != ENV.fetch('FLOW_BASE_COUNTRY').upcase
65
+ end
66
+
67
+ # because we do not get full experience from session, we have to get from exp list
68
+ def delivered_duty_options
69
+ Hashie::Mash.new Flow::Experience.get(experience.key).settings.delivered_duty.to_hash
70
+ end
71
+
72
+ # if we have more than one choice, we show choice popup
73
+ def offers_delivered_duty_choice?
74
+ delivered_duty_options.available.length > 1
75
+ end
76
+ end
77
+
@@ -0,0 +1,30 @@
1
+ # Flow.io (2017)
2
+ # Module uses rails engine for encrypt and decrypt
3
+
4
+ # example
5
+ # enc1 = Flow::SimpleCrypt.encrypt('foo')
6
+ # Flow::SimpleCrypt.encrypt(enc1)
7
+ #
8
+ # example with salt
9
+ # enc2 = Flow::SimpleCrypt.encrypt('bar', '127.0.0.1')
10
+ # Flow::SimpleCrypt.encrypt(enc2) # raises error: ActiveSupport::MessageVerifier::InvalidSignature
11
+ # Flow::SimpleCrypt.encrypt(enc2, '127.0.0.1') # ok
12
+
13
+ module Flow::SimpleCrypt
14
+ extend self
15
+
16
+ def encrypt_base(salt)
17
+ local_secret = Rails.application.secrets.secret_key_base[0,32]
18
+ key = ActiveSupport::KeyGenerator.new(local_secret).generate_key(salt || '', 32)
19
+
20
+ ActiveSupport::MessageEncryptor.new(key)
21
+ end
22
+
23
+ def encrypt(raw_data, salt=nil)
24
+ encrypt_base(salt).encrypt_and_sign(raw_data)
25
+ end
26
+
27
+ def decrypt(enc_data, salt=nil)
28
+ encrypt_base(salt).decrypt_and_verify(enc_data)
29
+ end
30
+ end
@@ -0,0 +1,123 @@
1
+ # Flow.io (2017)
2
+ # communicates with Flow payments API, easy access to session
3
+ # to basic shop frontend and backend needs
4
+
5
+ class Flow::SimpleGateway
6
+ cattr_accessor :clear_zero_amount_payments
7
+
8
+ def initialize(order)
9
+ @order = order
10
+ end
11
+
12
+ # authorises credit card and prepares for capture
13
+ def cc_authorization
14
+ auth_form = get_authorization_form
15
+ response = FlowCommerce.instance.authorizations.post(Flow.organization, auth_form)
16
+ status_message = response.result.status.value
17
+ status = status_message == ::Io::Flow::V0::Models::AuthorizationStatus.authorized.value
18
+
19
+ store = {
20
+ key: response.key,
21
+ amount: response.amount,
22
+ currency: response.currency,
23
+ authorization_id: response.id
24
+ }
25
+
26
+ @order.update_column :flow_data, @order.flow_data.merge('authorization': store)
27
+
28
+ if self.class.clear_zero_amount_payments
29
+ @order.payments.where(amount:0, state: ['invalid', 'processing', 'pending']).map(&:destroy)
30
+ end
31
+
32
+ ActiveMerchant::Billing::Response.new(status, status_message, { response: response }, { authorization: store })
33
+ rescue Io::Flow::V0::HttpClient::ServerError => exception
34
+ error_response(exception)
35
+ end
36
+
37
+ # capture authorised funds
38
+ def cc_capture
39
+ # GET /:organization/authorizations, order_number: abc
40
+ data = @order.flow_data['authorization']
41
+
42
+ raise ArgumentError, 'No Authorization data, please authorize first' unless data
43
+
44
+ capture_form = ::Io::Flow::V0::Models::CaptureForm.new(data)
45
+ response = FlowCommerce.instance.captures.post(Flow.organization, capture_form)
46
+
47
+ if response.id
48
+ @order.update_column :flow_data, @order.flow_data.merge('capture': response.to_hash)
49
+ @order.flow_finalize!
50
+
51
+ ActiveMerchant::Billing::Response.new true, 'success', { response: response }
52
+ else
53
+ ActiveMerchant::Billing::Response.new false, 'error', { response: response }
54
+ end
55
+ rescue => exception
56
+ error_response(exception)
57
+ end
58
+
59
+ def cc_refund
60
+ raise ArgumentError, 'capture info is not available' unless @order.flow_data['capture']
61
+
62
+ # we allways have capture ID, so we use it
63
+ refund_data = { capture_id: @order.flow_data['capture']['id'] }
64
+ refund_form = ::Io::Flow::V0::Models::RefundForm.new(refund_data)
65
+ response = FlowCommerce.instance.refunds.post(Flow.organization, refund_form)
66
+
67
+ if response.id
68
+ @order.update_column :flow_data, @order.flow_data.merge('refund': response.to_hash)
69
+ ActiveMerchant::Billing::Response.new true, 'success', { response: response }
70
+ else
71
+ ActiveMerchant::Billing::Response.new false, 'error', { response: response }
72
+ end
73
+ rescue => exception
74
+ error_response(exception)
75
+ end
76
+
77
+ private
78
+
79
+ # if order is not in flow, we use local solidus settings
80
+ def in_flow?
81
+ @order.flow_order ? true : false
82
+ end
83
+
84
+ def get_authorization_form
85
+ if in_flow?
86
+ # we have order id so we allways use MerchantOfRecordAuthorizationForm
87
+ ::Io::Flow::V0::Models::MerchantOfRecordAuthorizationForm.new({
88
+ 'order_number': @order.flow_number,
89
+ 'currency': @order.flow_order.total.currency,
90
+ 'amount': @order.flow_order.total.amount,
91
+ 'token': cc_get_token,
92
+ })
93
+ else
94
+ # when not using flow, we fall back to solidus default
95
+ ::Io::Flow::V0::Models::DirectAuthorizationForm.new({
96
+ 'currency': @order.currency,
97
+ 'amount': @order.total,
98
+ 'token': cc_get_token,
99
+ })
100
+ end
101
+ end
102
+
103
+ # gets credit card token
104
+ def cc_get_token
105
+ cards = @order.credit_cards.select{ |cc| cc[:flow_data]['cc_token'] }
106
+ raise StandarError.new('Credit card not found') unless cards.first
107
+
108
+ cards.first.flow_data['cc_token'] || raise(StandardError.new 'Flow credit card token not found')
109
+ end
110
+
111
+ # we want to return errors in standardized format
112
+ def error_response(exception_object, message=nil)
113
+ message = if exception_object.respond_to?(:body) && exception_object.body.length > 0
114
+ description = JSON.load(exception_object.body)['messages'].to_sentence
115
+ '%s: %s (%s)' % [exception_object.details, description, exception_object.code]
116
+ else
117
+ exception_object.message
118
+ end
119
+
120
+ ActiveMerchant::Billing::Response.new(false, message, exception: exception_object)
121
+ end
122
+ end
123
+