flowcommerce_spree 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +91 -0
  4. data/Rakefile +33 -0
  5. data/SPREE_FLOW.md +134 -0
  6. data/app/assets/javascripts/flowcommerce_spree/application.js +13 -0
  7. data/app/assets/stylesheets/flowcommerce_spree/application.css +15 -0
  8. data/app/controllers/concerns/current_zone_loader_decorator.rb +49 -0
  9. data/app/controllers/flowcommerce_spree/webhooks_controller.rb +25 -0
  10. data/app/helpers/flowcommerce_spree/application_helper.rb +6 -0
  11. data/app/helpers/spree/admin/orders_helper_decorator.rb +17 -0
  12. data/app/helpers/spree/core/controller_helpers/flow_io_order_helper_decorator.rb +53 -0
  13. data/app/mailers/spree/spree_order_mailer_decorator.rb +24 -0
  14. data/app/models/flowcommerce_spree/settings.rb +8 -0
  15. data/app/models/spree/credit_card_decorator.rb +9 -0
  16. data/app/models/spree/flow_io_product_decorator.rb +91 -0
  17. data/app/models/spree/flow_io_variant_decorator.rb +205 -0
  18. data/app/models/spree/gateway/spree_flow_gateway.rb +116 -0
  19. data/app/models/spree/line_item_decorator.rb +15 -0
  20. data/app/models/spree/order_decorator.rb +179 -0
  21. data/app/models/spree/promotion_decorator.rb +10 -0
  22. data/app/models/spree/promotion_handler/coupon_decorator.rb +30 -0
  23. data/app/models/spree/spree_user_decorator.rb +15 -0
  24. data/app/models/spree/taxon_decorator.rb +37 -0
  25. data/app/models/spree/zone_decorator.rb +7 -0
  26. data/app/models/spree/zones/flow_io_product_zone_decorator.rb +55 -0
  27. data/app/services/flowcommerce_spree/import_experience_items.rb +76 -0
  28. data/app/services/flowcommerce_spree/import_experiences.rb +37 -0
  29. data/app/services/flowcommerce_spree/order_sync.rb +231 -0
  30. data/app/views/layouts/flowcommerce_spree/application.html.erb +14 -0
  31. data/app/views/spree/admin/payments/index.html.erb +28 -0
  32. data/app/views/spree/admin/promotions/edit.html.erb +57 -0
  33. data/app/views/spree/admin/shared/_order_summary.html.erb +44 -0
  34. data/app/views/spree/admin/shared/_order_summary_flow.html.erb +13 -0
  35. data/app/views/spree/order_mailer/confirm_email.html.erb +86 -0
  36. data/app/views/spree/order_mailer/confirm_email.text.erb +38 -0
  37. data/config/initializers/flowcommerce_spree.rb +7 -0
  38. data/config/routes.rb +5 -0
  39. data/db/migrate/20201021160159_add_type_and_meta_to_spree_zone.rb +23 -0
  40. data/db/migrate/20201021755957_add_meta_to_spree_tables.rb +17 -0
  41. data/db/migrate/20201022173210_add_zone_type_to_spree_zone_members.rb +24 -0
  42. data/db/migrate/20201022174252_add_kind_to_zone.rb +22 -0
  43. data/lib/flow/error.rb +73 -0
  44. data/lib/flow/pay_pal.rb +25 -0
  45. data/lib/flow/simple_gateway.rb +115 -0
  46. data/lib/flowcommerce_spree.rb +31 -0
  47. data/lib/flowcommerce_spree/api.rb +48 -0
  48. data/lib/flowcommerce_spree/engine.rb +27 -0
  49. data/lib/flowcommerce_spree/experience_service.rb +65 -0
  50. data/lib/flowcommerce_spree/logging_http_client.rb +43 -0
  51. data/lib/flowcommerce_spree/logging_http_handler.rb +15 -0
  52. data/lib/flowcommerce_spree/refresher.rb +81 -0
  53. data/lib/flowcommerce_spree/session.rb +71 -0
  54. data/lib/flowcommerce_spree/version.rb +5 -0
  55. data/lib/flowcommerce_spree/webhook_service.rb +98 -0
  56. data/lib/simple_csv_writer.rb +44 -0
  57. data/lib/tasks/flowcommerce_spree.rake +289 -0
  58. metadata +220 -0
@@ -0,0 +1,38 @@
1
+ <%= Spree.t('order_mailer.confirm_email.dear_customer') %>
2
+
3
+ <%= Spree.t('order_mailer.confirm_email.instructions') %>
4
+
5
+ ============================================================
6
+ <%= Spree.t('order_mailer.confirm_email.order_summary') %>
7
+ ============================================================
8
+ <% @order.line_items.each do |item| %>
9
+ <%= item.variant.sku %> <%= raw(item.variant.product.name) %> <%= raw(item.variant.options_text) -%> (<%=item.quantity%>) @ <%= item.single_money %> = <%= @order.flow_line_item_price(line_item, :with_quantity) %>
10
+ <% end %>
11
+ ============================================================
12
+ <%= Spree.t('order_mailer.confirm_email.subtotal', :subtotal => @order.display_item_total) %>
13
+ <% if @order.line_item_adjustments.exists? %>
14
+ <% if @order.all_adjustments.promotion.eligible.exists? %>
15
+ <% @order.all_adjustments.promotion.eligible.group_by(&:label).each do |label, adjustments| %>
16
+ <%= Spree.t(:promotion) %>: <%= label %> <%= Spree::Money.new(adjustments.sum(&:amount), currency: @order.currency) %>
17
+ <% end %>
18
+ <% end %>
19
+ <% end %>
20
+
21
+ <% @order.shipments.group_by { |s| s.selected_shipping_rate.try(:name) }.each do |name, shipments| %>
22
+ <%= Spree.t(:shipping) %>: <%= name %> <%= Spree::Money.new(shipments.sum(&:discounted_cost), currency: @order.currency) %>
23
+ <% end %>
24
+
25
+ <% if @order.all_adjustments.eligible.tax.exists? %>
26
+ <% @order.all_adjustments.eligible.tax.group_by(&:label).each do |label, adjustments| %>
27
+ <%= Spree.t(:tax) %>: <%= label %> <%= Spree::Money.new(adjustments.sum(&:amount), currency: @order.currency) %>
28
+ <% end %>
29
+ <% end %>
30
+
31
+ <% @order.adjustments.eligible.each do |adjustment| %>
32
+ <% next if (adjustment.source_type == 'Spree::TaxRate') and (adjustment.amount == 0) %>
33
+ <%= adjustment.label %> <%= adjustment.display_amount %>
34
+ <% end %>
35
+ ============================================================
36
+ <%= Spree.t('order_mailer.confirm_email.total', :total => @order.display_total) %>
37
+
38
+ <%= Spree.t('order_mailer.confirm_email.thanks') %>
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowcommerceSpree
4
+ ORGANIZATION = ENV.fetch('FLOW_ORGANIZATION', 'flow.io')
5
+ BASE_COUNTRY = ENV.fetch('FLOW_BASE_COUNTRY', 'USA')
6
+ API_KEY = ENV.fetch('FLOW_TOKEN', 'test_key')
7
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ FlowcommerceSpree::Engine.routes.draw do
4
+ post '/event-target', to: 'webhooks#handle_flow_web_hook_event'
5
+ end
@@ -0,0 +1,23 @@
1
+ class AddTypeAndMetaToSpreeZone < ActiveRecord::Migration
2
+ def up
3
+ add_column :spree_zones, :klass, :text unless column_exists?(:spree_zones, :klass)
4
+ add_column :spree_zones, :status, :text unless column_exists?(:spree_zones, :status)
5
+ add_column :spree_zones, :meta, :jsonb, default: '{}' unless column_exists?(:spree_zones, :meta)
6
+
7
+ add_index :spree_zones, :meta, using: :gin unless index_exists?(:spree_zones, :meta)
8
+ add_index :spree_zones, %i[id klass] unless index_exists?(:spree_zones, %i[id klass])
9
+ add_index :spree_zones, %i[klass name], unique: true unless index_exists?(:spree_zones, %i[klass name])
10
+ add_index :spree_zones, :status unless index_exists?(:spree_zones, :status)
11
+ end
12
+
13
+ def down
14
+ remove_index :spree_zones, :status if index_exists?(:spree_zones, :status)
15
+ remove_index :spree_zones, %i[klass name] if index_exists?(:spree_zones, %i[klass name])
16
+ remove_index :spree_zones, %i[id klass] if index_exists?(:spree_zones, %i[id klass])
17
+ remove_index :spree_zones, :meta if index_exists?(:spree_zones, :meta)
18
+
19
+ remove_column :spree_zones, :meta if column_exists?(:spree_zones, :meta)
20
+ remove_column :spree_zones, :status if column_exists?(:spree_zones, :status)
21
+ remove_column :spree_zones, :klass if column_exists?(:spree_zones, :klass)
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ class AddMetaToSpreeTables < ActiveRecord::Migration
2
+ def up
3
+ add_column :spree_products, :meta, :jsonb, default: '{}' unless column_exists?(:spree_products, :meta)
4
+ add_column :spree_variants, :meta, :jsonb, default: '{}' unless column_exists?(:spree_variants, :meta)
5
+ add_column :spree_orders, :meta, :jsonb, default: '{}' unless column_exists?(:spree_orders, :meta)
6
+ add_column :spree_promotions, :meta, :jsonb, default: '{}' unless column_exists?(:spree_promotions, :meta)
7
+ add_column :spree_credit_cards, :meta, :jsonb, default: '{}' unless column_exists?(:spree_credit_cards, :meta)
8
+ end
9
+
10
+ def down
11
+ remove_column :spree_products, :meta if column_exists?(:spree_products, :meta)
12
+ remove_column :spree_variants, :meta if column_exists?(:spree_variants, :meta)
13
+ remove_column :spree_orders, :meta if column_exists?(:spree_orders, :meta)
14
+ remove_column :spree_promotions, :meta if column_exists?(:spree_promotions, :meta)
15
+ remove_column :spree_credit_cards, :meta if column_exists?(:spree_credit_cards, :meta)
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ class AddZoneTypeToSpreeZoneMembers < ActiveRecord::Migration
2
+ def up
3
+ add_column :spree_zone_members, :zone_type, :text unless column_exists?(:spree_zone_members, :zone_type)
4
+
5
+ unless index_exists?(:spree_zone_members, %i[zone_id zone_type])
6
+ add_index :spree_zone_members, %i[zone_id zone_type],
7
+ name: "index_spree_zone_members_on_zone_id_and_zone_type", using: :btree
8
+ end
9
+
10
+ if index_exists?(:spree_zone_members, name: "index_spree_zone_members_on_zone_id")
11
+ remove_index :spree_zone_members, name: "index_spree_zone_members_on_zone_id"
12
+ end
13
+ end
14
+
15
+ def down
16
+ add_index :spree_zone_members, :zone_id unless index_exists?(:spree_zone_members, :zone_id)
17
+
18
+ if index_exists?(:spree_zone_members, %i[zone_id zone_type])
19
+ remove_index :spree_zone_members, name: "index_spree_zone_members_on_zone_id_and_zone_type"
20
+ end
21
+
22
+ remove_column :spree_zone_members, :zone_type if column_exists?(:spree_zone_members, :zone_type)
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ # This is from Spree 3.0
2
+ # https://github.com/spree/spree/commit/f9509a511def39de9d98199ddbf35f35c8580ca4#diff-984b308f2dc59ffb6e47183ac28b9895cfaa58bb26fb6f6e56a6afbe888fdece
3
+ class AddKindToZone < ActiveRecord::Migration
4
+ def up
5
+ unless column_exists?(:spree_zones, :kind)
6
+ add_column :spree_zones, :kind, :string
7
+ add_index :spree_zones, :kind
8
+
9
+ Spree::Zone.find_each do |zone|
10
+ last_type = zone.members.where.not(zoneable_type: nil).pluck(:zoneable_type).last
11
+ zone.update_column :kind, last_type.demodulize.underscore if last_type
12
+ end
13
+ end
14
+
15
+ add_index :spree_zones, :kind unless index_exists?(:spree_zones, :kind)
16
+ end
17
+
18
+ def down
19
+ remove_index :spree_zones, :kind if index_exists?(:spree_zones, :kind)
20
+ remove_column :spree_zones, :kind if column_exists?(:spree_zones, :kind)
21
+ end
22
+ end
data/lib/flow/error.rb ADDED
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Flow (2017)
4
+ # api error logger and formater
5
+
6
+ require 'digest/sha1'
7
+
8
+ class Flow::Error < StandardError
9
+ # logs error to file for easy discovery and fix
10
+ def self.log(exception, request)
11
+ history = exception.backtrace.reject { |el| el.index('/gems/') }.map { |el| el.sub(Rails.root.to_s, '') }.join($/)
12
+
13
+ msg = "#{exception.class} in #{request.url}"
14
+ data = [msg, exception.message, history].join("\n\n")
15
+ key = Digest::SHA1.hexdigest(exception.backtrace.first.split(' ').first)
16
+
17
+ folder = Rails.root.join('log/exceptions').to_s
18
+ Dir.mkdir(folder) unless Dir.exist?(folder)
19
+
20
+ folder += "/#{exception.class.to_s.tableize.gsub('/', '-')}"
21
+ Dir.mkdir(folder) unless Dir.exist?(folder)
22
+
23
+ "#{folder}/#{key}.txt".tap do |path|
24
+ File.write(path, data)
25
+ end
26
+ end
27
+
28
+ def self.format_message(exception)
29
+ # format Flow errors in a special way
30
+ # Io::Flow::V0::HttpClient::ServerError - 422 Unprocessable Entity:
31
+ # {"code":"invalid_number","messages":["Card number is not valid"]}
32
+ # hash['code'] = 'invalid_number'
33
+ # hash['message'] = 'Card number is not valid'
34
+ # hash['title'] = '422 Unprocessable Entity'
35
+ # hash['klass'] = 'Io::Flow::V0::HttpClient::ServerError'
36
+ if exception.class == Io::Flow::V0::HttpClient::ServerError
37
+ parts = exception.message.split(': ', 2)
38
+ hash = Oj.load(parts[1])
39
+
40
+ hash[:message] = hash['messages'].join(', ')
41
+ hash[:title] = parts[0]
42
+ hash[:klass] = exception.class
43
+ hash[:code] = hash['code']
44
+ else
45
+ msg = exception.message.is_a?(Array) ? exception.message.join(' - ') : exception.message
46
+
47
+ hash = {}
48
+ hash[:message] = msg
49
+ hash[:title] = '-'
50
+ hash[:klass] = exception.class
51
+ hash[:code] = '-'
52
+ end
53
+
54
+ hash
55
+ end
56
+
57
+ def self.format_order_message(order)
58
+ message = if order['messages']
59
+ msg = order['messages'].join(', ')
60
+ msg += " (#{Spree::Variant.where(id: order['numbers']).map(&:name).join(', ')})" if order['numbers']
61
+ msg
62
+ else
63
+ 'Order not properly localized (sync issue)'
64
+ end
65
+
66
+ # sub_info = 'Flow.io'
67
+ # sub_info += ' - %s' % flow_experience.key[0, 15] if flow_experience
68
+
69
+ # '%s (%s)' % [message, sub_info]
70
+
71
+ message
72
+ end
73
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Flow.io (2017)
4
+ # communicates with flow api to synchronize Spree order with PayPal
5
+
6
+ module Flow::PayPal
7
+ extend self
8
+
9
+ def get_id(order)
10
+ raise 'PayPal only supported while using flow' unless order.flow_order
11
+
12
+ # get PayPal ID using Flow api
13
+ body = {
14
+ # discriminator: 'merchant_of_record_payment_form',
15
+ method: 'paypal',
16
+ order_number: order.number,
17
+ amount: order.flow_order.total.amount,
18
+ currency: order.flow_order.total.currency
19
+ }
20
+
21
+ # FlowcommerceSpree::Api.run :post, '/:organization/payments', {}, body
22
+ form = ::Io::Flow::V0::Models::MerchantOfRecordPaymentForm.new body
23
+ FlowcommerceSpree.client.payments.post FlowcommerceSpree::ORGANIZATION, form
24
+ end
25
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Flow.io (2017)
4
+ # communicates with Flow payments API, easy access to session
5
+ # to basic shop frontend and backend needs
6
+ module Flow
7
+ class SimpleGateway
8
+ cattr_accessor :clear_zero_amount_payments
9
+
10
+ def initialize(order)
11
+ @order = order
12
+ end
13
+
14
+ # authorises credit card and prepares for capture
15
+ def cc_authorization
16
+ response = FlowcommerceSpree.client.authorizations.post(FlowcommerceSpree::ORGANIZATION, build_authorization_form)
17
+ status_message = response.result.status.value
18
+ status = status_message == ::Io::Flow::V0::Models::AuthorizationStatus.authorized.value
19
+
20
+ store = { key: response.key,
21
+ amount: response.amount,
22
+ currency: response.currency,
23
+ authorization_id: response.id }
24
+
25
+ @order.flow_data['authorization'] = store
26
+ @order.update_column(:meta, @order.meta.to_json)
27
+
28
+ if self.class.clear_zero_amount_payments
29
+ @order.payments.where(amount: 0, state: %w[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 => e
34
+ error_response(e)
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 = FlowcommerceSpree.client.captures.post(FlowcommerceSpree::ORGANIZATION, capture_form)
46
+
47
+ return ActiveMerchant::Billing::Response.new false, 'error', response: response unless response.id
48
+
49
+ @order.update_column :flow_data, @order.flow_data.merge('capture': response.to_hash)
50
+ @order.flow_finalize!
51
+
52
+ ActiveMerchant::Billing::Response.new true, 'success', response: response
53
+ rescue StandardError => e
54
+ error_response(e)
55
+ end
56
+
57
+ def cc_refund
58
+ raise ArgumentError, 'capture info is not available' unless @order.flow_data['capture']
59
+
60
+ # we allways have capture ID, so we use it
61
+ refund_data = { capture_id: @order.flow_data['capture']['id'] }
62
+ refund_form = ::Io::Flow::V0::Models::RefundForm.new(refund_data)
63
+ response = FlowcommerceSpree.client.refunds.post(FlowcommerceSpree::ORGANIZATION, refund_form)
64
+
65
+ return ActiveMerchant::Billing::Response.new false, 'error', response: response unless response.id
66
+
67
+ @order.update_column :flow_data, @order.flow_data.merge('refund': response.to_hash)
68
+ ActiveMerchant::Billing::Response.new true, 'success', response: response
69
+ rescue StandardError => e
70
+ error_response(e)
71
+ end
72
+
73
+ private
74
+
75
+ # if order is not in flow, we use local Spree settings
76
+ def in_flow?
77
+ @order.flow_order ? true : false
78
+ end
79
+
80
+ def build_authorization_form
81
+ if in_flow?
82
+ # we have order id so we allways use MerchantOfRecordAuthorizationForm
83
+ ::Io::Flow::V0::Models::MerchantOfRecordAuthorizationForm.new('order_number': @order.flow_number,
84
+ 'currency': @order.flow_order.total.currency,
85
+ 'amount': @order.flow_order.total.amount,
86
+ 'token': cc_get_token)
87
+ else
88
+ # when not using Flow, we fall back to Spree default
89
+ ::Io::Flow::V0::Models::DirectAuthorizationForm.new('currency': @order.currency,
90
+ 'amount': @order.total,
91
+ 'token': cc_get_token)
92
+ end
93
+ end
94
+
95
+ # gets credit card token
96
+ def cc_get_token
97
+ cards = @order.credit_cards.select(&:gateway_customer_profile_id)
98
+ raise StandardError, 'Credit card with token not found' unless cards.first
99
+
100
+ cards.first.gateway_customer_profile_id
101
+ end
102
+
103
+ # we want to return errors in standardized format
104
+ def error_response(exception_object)
105
+ message = if exception_object.respond_to?(:body) && exception_object.body.length > 0
106
+ description = Oj.load(exception_object.body)['messages'].to_sentence
107
+ "#{exception_object.details}: #{description} (#{exception_object.code})"
108
+ else
109
+ exception_object.message
110
+ end
111
+
112
+ ActiveMerchant::Billing::Response.new(false, message, exception: exception_object)
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'flowcommerce'
4
+ require 'flowcommerce_spree/api'
5
+ require 'flowcommerce_spree/refresher'
6
+ require 'flowcommerce_spree/engine'
7
+ require 'flowcommerce_spree/logging_http_client'
8
+ require 'flowcommerce_spree/logging_http_handler'
9
+ require 'flowcommerce_spree/webhook_service'
10
+ require 'flowcommerce_spree/session'
11
+ require 'flow/simple_gateway'
12
+
13
+ module FlowcommerceSpree
14
+ def self.client(logger: FlowcommerceSpree.logger, **opts)
15
+ FlowCommerce.instance(http_handler: LoggingHttpHandler.new(logger: logger), **opts)
16
+ end
17
+
18
+ def self.configure
19
+ yield self if block_given?
20
+ end
21
+
22
+ def self.logger
23
+ logger = ActiveSupport::Logger.new(STDOUT, 3, 10_485_760)
24
+
25
+ # Broadcast the log into the file besides STDOUT, if `log` folder exists
26
+ if Dir.exist?('log')
27
+ logger.extend(ActiveSupport::Logger.broadcast(ActiveSupport::Logger.new('log/flowcommerce_spree.log')))
28
+ end
29
+ logger
30
+ end
31
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # module for communication and customization based on Flow API
4
+ # for now all in same class
5
+ module FlowcommerceSpree
6
+ module Api
7
+ extend self
8
+
9
+ # builds curl command and gets remote data
10
+ def run(action, path, params = {}, body = nil)
11
+ body ||= params.delete(:BODY)
12
+
13
+ remote_params = URI.encode_www_form params
14
+ remote_path = debug_path = path.sub('%o', ORGANIZATION).sub(':organization', ORGANIZATION)
15
+ remote_path += "?#{remote_params}" unless remote_params.blank?
16
+
17
+ curl = ['curl -s']
18
+ curl.push "-X #{action.to_s.upcase}"
19
+ curl.push "-u #{API_KEY}:"
20
+
21
+ if body
22
+ body = body.to_json unless body.is_a?(Array)
23
+ curl.push '-H "Content-Type: application/json"'
24
+ curl.push "-d '#{body.gsub(%['], %['"'"'])}'" if body
25
+ end
26
+
27
+ curl.push "\"https://api.flow.io#{remote_path}\""
28
+ command = curl.join(' ')
29
+
30
+ puts command if defined?(Rails::Console)
31
+
32
+ dir = Rails.root.join('log/api')
33
+ Dir.mkdir(dir) unless Dir.exist?(dir)
34
+ debug_file = "#{dir}/#{debug_path.gsub(/[^\w]+/, '_')}.bash"
35
+ File.write debug_file, command + "\n"
36
+
37
+ JSON.load `#{command}`
38
+ end
39
+
40
+ def logger
41
+ @logger ||= Logger.new('./log/flow.log') # or nil for no logging
42
+ end
43
+
44
+ def format_default_price(amount)
45
+ format('$%<price>.2f', amount)
46
+ end
47
+ end
48
+ end