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