flowcommerce-solidus 0.1.11

Sign up to get free protection for your applications and to get access to all the features.
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
+