disco_app 0.6.8

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 (158) hide show
  1. checksums.yaml +7 -0
  2. data/Rakefile +37 -0
  3. data/app/assets/images/disco_app/icon.svg +1 -0
  4. data/app/assets/javascripts/disco_app/components/shopify_admin_link.js.jsx +29 -0
  5. data/app/assets/javascripts/disco_app/components.js +5 -0
  6. data/app/assets/javascripts/disco_app/disco_app.js +7 -0
  7. data/app/assets/javascripts/disco_app/frame.js +152 -0
  8. data/app/assets/javascripts/disco_app/shopify-turbolinks.js +7 -0
  9. data/app/assets/stylesheets/disco_app/bootstrap/_custom.scss +54 -0
  10. data/app/assets/stylesheets/disco_app/bootstrap/_variables.scss +872 -0
  11. data/app/assets/stylesheets/disco_app/disco/_buttons.scss +31 -0
  12. data/app/assets/stylesheets/disco_app/disco/_cards.scss +51 -0
  13. data/app/assets/stylesheets/disco_app/disco/_forms.scss +23 -0
  14. data/app/assets/stylesheets/disco_app/disco/_grid.scss +58 -0
  15. data/app/assets/stylesheets/disco_app/disco/_sections.scss +61 -0
  16. data/app/assets/stylesheets/disco_app/disco/_tables.scss +57 -0
  17. data/app/assets/stylesheets/disco_app/disco/_tabs.scss +61 -0
  18. data/app/assets/stylesheets/disco_app/disco/_type.scss +39 -0
  19. data/app/assets/stylesheets/disco_app/disco/mixins/_flexbox.scss +394 -0
  20. data/app/assets/stylesheets/disco_app/disco_app.scss +16 -0
  21. data/app/assets/stylesheets/disco_app/frame/_buttons.scss +54 -0
  22. data/app/assets/stylesheets/disco_app/frame/_forms.scss +26 -0
  23. data/app/assets/stylesheets/disco_app/frame/_layout.scss +77 -0
  24. data/app/assets/stylesheets/disco_app/frame/_type.scss +32 -0
  25. data/app/assets/stylesheets/disco_app/frame.scss +9 -0
  26. data/app/controllers/disco_app/app_proxy_controller.rb +41 -0
  27. data/app/controllers/disco_app/authenticated_controller.rb +44 -0
  28. data/app/controllers/disco_app/carrier_request_controller.rb +28 -0
  29. data/app/controllers/disco_app/charges_controller.rb +30 -0
  30. data/app/controllers/disco_app/frame_controller.rb +9 -0
  31. data/app/controllers/disco_app/install_controller.rb +26 -0
  32. data/app/controllers/disco_app/webhooks_controller.rb +42 -0
  33. data/app/helpers/disco_app/application_helper.rb +28 -0
  34. data/app/jobs/disco_app/app_installed_job.rb +3 -0
  35. data/app/jobs/disco_app/app_uninstalled_job.rb +3 -0
  36. data/app/jobs/disco_app/concerns/app_installed_job.rb +22 -0
  37. data/app/jobs/disco_app/concerns/app_uninstalled_job.rb +20 -0
  38. data/app/jobs/disco_app/concerns/shop_update_job.rb +16 -0
  39. data/app/jobs/disco_app/concerns/synchronise_carrier_service_job.rb +51 -0
  40. data/app/jobs/disco_app/concerns/synchronise_webhooks_job.rb +53 -0
  41. data/app/jobs/disco_app/shop_job.rb +27 -0
  42. data/app/jobs/disco_app/shop_update_job.rb +3 -0
  43. data/app/jobs/disco_app/synchronise_carrier_service_job.rb +3 -0
  44. data/app/jobs/disco_app/synchronise_webhooks_job.rb +3 -0
  45. data/app/models/disco_app/concerns/plan.rb +14 -0
  46. data/app/models/disco_app/concerns/shop.rb +68 -0
  47. data/app/models/disco_app/concerns/subscription.rb +14 -0
  48. data/app/models/disco_app/plan.rb +3 -0
  49. data/app/models/disco_app/session_storage.rb +18 -0
  50. data/app/models/disco_app/shop.rb +3 -0
  51. data/app/models/disco_app/subscription.rb +3 -0
  52. data/app/services/disco_app/charges_service.rb +73 -0
  53. data/app/services/disco_app/subscription_service.rb +25 -0
  54. data/app/services/disco_app/webhook_service.rb +30 -0
  55. data/app/views/disco_app/charges/activate.html.erb +1 -0
  56. data/app/views/disco_app/charges/create.html.erb +1 -0
  57. data/app/views/disco_app/charges/new.html.erb +45 -0
  58. data/app/views/disco_app/frame/frame.html.erb +36 -0
  59. data/app/views/disco_app/install/installing.html.erb +7 -0
  60. data/app/views/disco_app/install/uninstalling.html.erb +1 -0
  61. data/app/views/disco_app/proxy_errors/404.html.erb +1 -0
  62. data/app/views/disco_app/shared/_card.html.erb +14 -0
  63. data/app/views/disco_app/shared/_section.html.erb +17 -0
  64. data/app/views/layouts/application.html.erb +18 -0
  65. data/app/views/layouts/embedded_app.html.erb +41 -0
  66. data/app/views/layouts/embedded_app_modal.html.erb +17 -0
  67. data/app/views/sessions/new.html.erb +26 -0
  68. data/config/routes.rb +26 -0
  69. data/db/migrate/20150525000000_create_shops_if_not_existent.rb +15 -0
  70. data/db/migrate/20150525162112_add_status_to_shops.rb +5 -0
  71. data/db/migrate/20150525171422_add_meta_to_shops.rb +11 -0
  72. data/db/migrate/20150629210346_add_charge_status_to_shop.rb +5 -0
  73. data/db/migrate/20150814214025_add_more_meta_to_shops.rb +15 -0
  74. data/db/migrate/20151017231302_create_disco_app_plans.rb +13 -0
  75. data/db/migrate/20151017232027_create_disco_app_subscriptions.rb +15 -0
  76. data/db/migrate/20151017234409_move_shop_to_disco_app_engine.rb +5 -0
  77. data/lib/disco_app/engine.rb +23 -0
  78. data/lib/disco_app/support/file_fixtures.rb +23 -0
  79. data/lib/disco_app/test_help.rb +11 -0
  80. data/lib/disco_app/version.rb +3 -0
  81. data/lib/disco_app.rb +4 -0
  82. data/lib/generators/disco_app/USAGE +5 -0
  83. data/lib/generators/disco_app/disco_app_generator.rb +154 -0
  84. data/lib/generators/disco_app/mailify/mailify_generator.rb +54 -0
  85. data/lib/generators/disco_app/reactify/reactify_generator.rb +45 -0
  86. data/lib/generators/disco_app/templates/assets/javascripts/application.js +17 -0
  87. data/lib/generators/disco_app/templates/assets/stylesheets/application.scss +5 -0
  88. data/lib/generators/disco_app/templates/config/puma.rb +15 -0
  89. data/lib/generators/disco_app/templates/controllers/home_controller.rb +7 -0
  90. data/lib/generators/disco_app/templates/initializers/disco_app.rb +1 -0
  91. data/lib/generators/disco_app/templates/initializers/shopify_app.rb +7 -0
  92. data/lib/generators/disco_app/templates/initializers/shopify_session_repository.rb +7 -0
  93. data/lib/generators/disco_app/templates/root/Procfile +2 -0
  94. data/lib/generators/disco_app/templates/views/home/index.html.erb +2 -0
  95. data/lib/tasks/carrier_service.rake +10 -0
  96. data/lib/tasks/start.rake +3 -0
  97. data/lib/tasks/webhooks.rake +10 -0
  98. data/test/controllers/disco_app/install_controller_test.rb +50 -0
  99. data/test/controllers/disco_app/webhooks_controller_test.rb +58 -0
  100. data/test/controllers/home_controller_test.rb +61 -0
  101. data/test/disco_app_test.rb +7 -0
  102. data/test/dummy/Rakefile +6 -0
  103. data/test/dummy/app/assets/javascripts/application.js +17 -0
  104. data/test/dummy/app/assets/stylesheets/application.scss +5 -0
  105. data/test/dummy/app/controllers/application_controller.rb +6 -0
  106. data/test/dummy/app/controllers/home_controller.rb +7 -0
  107. data/test/dummy/app/helpers/application_helper.rb +2 -0
  108. data/test/dummy/app/jobs/disco_app/app_uninstalled_job.rb +11 -0
  109. data/test/dummy/app/models/disco_app/shop.rb +15 -0
  110. data/test/dummy/app/views/home/index.html.erb +2 -0
  111. data/test/dummy/bin/bundle +3 -0
  112. data/test/dummy/bin/rails +4 -0
  113. data/test/dummy/bin/rake +4 -0
  114. data/test/dummy/bin/setup +29 -0
  115. data/test/dummy/config/application.rb +37 -0
  116. data/test/dummy/config/boot.rb +5 -0
  117. data/test/dummy/config/database.yml +25 -0
  118. data/test/dummy/config/environment.rb +5 -0
  119. data/test/dummy/config/environments/development.rb +41 -0
  120. data/test/dummy/config/environments/production.rb +85 -0
  121. data/test/dummy/config/environments/test.rb +42 -0
  122. data/test/dummy/config/initializers/assets.rb +11 -0
  123. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  124. data/test/dummy/config/initializers/cookies_serializer.rb +3 -0
  125. data/test/dummy/config/initializers/disco_app.rb +1 -0
  126. data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  127. data/test/dummy/config/initializers/inflections.rb +16 -0
  128. data/test/dummy/config/initializers/mime_types.rb +4 -0
  129. data/test/dummy/config/initializers/omniauth.rb +9 -0
  130. data/test/dummy/config/initializers/session_store.rb +3 -0
  131. data/test/dummy/config/initializers/shopify_app.rb +7 -0
  132. data/test/dummy/config/initializers/shopify_session_repository.rb +7 -0
  133. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  134. data/test/dummy/config/locales/en.yml +23 -0
  135. data/test/dummy/config/routes.rb +8 -0
  136. data/test/dummy/config/secrets.yml +22 -0
  137. data/test/dummy/config.ru +4 -0
  138. data/test/dummy/db/schema.rb +70 -0
  139. data/test/dummy/public/404.html +67 -0
  140. data/test/dummy/public/422.html +67 -0
  141. data/test/dummy/public/500.html +66 -0
  142. data/test/dummy/public/favicon.ico +0 -0
  143. data/test/fixtures/api/widget_store/shop.json +46 -0
  144. data/test/fixtures/api/widget_store/webhooks.json +1 -0
  145. data/test/fixtures/disco_app/plans.yml +32 -0
  146. data/test/fixtures/disco_app/shops.yml +10 -0
  147. data/test/fixtures/disco_app/subscriptions.yml +26 -0
  148. data/test/fixtures/webhooks/app_uninstalled.json +46 -0
  149. data/test/integration/navigation_test.rb +10 -0
  150. data/test/jobs/disco_app/app_installed_job_test.rb +30 -0
  151. data/test/jobs/disco_app/app_uninstalled_job_test.rb +32 -0
  152. data/test/models/disco_app/plan_test.rb +5 -0
  153. data/test/models/disco_app/shop_test.rb +26 -0
  154. data/test/models/disco_app/subscription_test.rb +6 -0
  155. data/test/services/disco_app/subscription_service_test.rb +28 -0
  156. data/test/support/test_file_fixtures.rb +29 -0
  157. data/test/test_helper.rb +51 -0
  158. metadata +484 -0
@@ -0,0 +1,28 @@
1
+ module DiscoApp
2
+ module CarrierRequestController
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ before_action :verify_carrier_request_signature
7
+ end
8
+
9
+ private
10
+
11
+ def verify_carrier_request_signature
12
+ unless carrier_request_signature_is_valid?
13
+ head :unauthorized
14
+ end
15
+ end
16
+
17
+ def carrier_request_signature_is_valid?
18
+ return true unless Rails.env.production?
19
+ data = request.body.read.to_s
20
+ hmac_header = request.headers['HTTP_X_SHOPIFY_HMAC_SHA256']
21
+ digest = OpenSSL::Digest::Digest.new('sha256')
22
+ calculated_hmac = Base64.encode64(OpenSSL::HMAC.digest(digest, ShopifyApp.configuration.secret, data)).strip
23
+ request.body.rewind
24
+ calculated_hmac == hmac_header
25
+ end
26
+
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ module DiscoApp
2
+ class ChargesController < ApplicationController
3
+ include DiscoApp::AuthenticatedController
4
+
5
+ skip_before_action :verify_status, only: [:create, :activate]
6
+
7
+ # Display a "pre-charge" page, giving the opportunity to explain why a charge needs to be made.
8
+ def new
9
+ end
10
+
11
+ # Create a new charge for the currently logged in shop, then redirect to the charge's confirmation URL.
12
+ def create
13
+ if (shopify_charge = DiscoApp::ChargesService.create(@shop)).nil?
14
+ redirect_to action: :new and return
15
+ end
16
+ redirect_to shopify_charge.confirmation_url
17
+ end
18
+
19
+ # Attempt to activate a charge after a user has accepted or declined it. Redirect to the main application's root URL
20
+ # immediately afterwards - if the charge wasn't accepted, the flow will start again.
21
+ def activate
22
+ if (shopify_charge = DiscoApp::ChargesService.get_accepted_charge(@shop, params[:charge_id])).nil?
23
+ redirect_to action: :new and return
24
+ end
25
+ DiscoApp::ChargesService.activate(@shop, shopify_charge)
26
+ redirect_to main_app.root_url
27
+ end
28
+
29
+ end
30
+ end
@@ -0,0 +1,9 @@
1
+ class DiscoApp::FrameController < ActionController::Base
2
+
3
+ layout nil
4
+
5
+ def frame
6
+
7
+ end
8
+
9
+ end
@@ -0,0 +1,26 @@
1
+ module DiscoApp
2
+ class InstallController < ApplicationController
3
+ include DiscoApp::AuthenticatedController
4
+
5
+ # Start the installation process for the current shop, then redirect to the installing screen.
6
+ def install
7
+ AppInstalledJob.perform_later(@shop.shopify_domain)
8
+ redirect_to action: :installing
9
+ end
10
+
11
+ # Display an "installing" page.
12
+ def installing
13
+ if @shop.installed?
14
+ redirect_to main_app.root_path
15
+ end
16
+ end
17
+
18
+ # Display an "uninstalling" page. Should be almost never used.
19
+ def uninstalling
20
+ if @shop.uninstalled?
21
+ redirect_to main_app.root_path
22
+ end
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ module DiscoApp
2
+ class WebhooksController < ActionController::Base
3
+
4
+ before_action :verify_webhook
5
+
6
+ def process_webhook
7
+ # Get the topic and domain for this webhook.
8
+ topic = request.headers['HTTP_X_SHOPIFY_TOPIC']
9
+ domain = request.headers['HTTP_X_SHOPIFY_SHOP_DOMAIN']
10
+
11
+ # Ensure a domain was provided in the headers.
12
+ unless domain
13
+ head :bad_request
14
+ end
15
+
16
+ # Try to find a matching background job task for the given topic using class name.
17
+ job_class = DiscoApp::WebhookService.find_job_class(topic)
18
+
19
+ # Return bad request if we couldn't match a job class.
20
+ unless job_class.present?
21
+ head :bad_request
22
+ end
23
+
24
+ # Decode the body data and enqueue the appropriate job.
25
+ data = ActiveSupport::JSON::decode(request.body.read).with_indifferent_access
26
+ job_class.perform_later(domain, data)
27
+
28
+ render nothing: true
29
+ end
30
+
31
+ private
32
+
33
+ # Verify a webhook request.
34
+ def verify_webhook
35
+ unless DiscoApp::WebhookService.is_valid_hmac?(request.body.read.to_s, ShopifyApp.configuration.secret, request.headers['HTTP_X_SHOPIFY_HMAC_SHA256'])
36
+ head :unauthorized
37
+ end
38
+ request.body.rewind
39
+ end
40
+
41
+ end
42
+ end
@@ -0,0 +1,28 @@
1
+ module DiscoApp::ApplicationHelper
2
+
3
+ # Generates a link pointing to an object (such as an order or customer) inside
4
+ # the given shop's Shopify admin. This helper makes it easy to create links
5
+ # to objects within the admin that support both right-clicking and opening in
6
+ # a new tab as well as capturing a left click and redirecting to the relevant
7
+ # object using `ShopifyApp.redirect()`.
8
+ def link_to_shopify_admin(shop, name, admin_path, options = {})
9
+ options[:onclick] = "ShopifyApp.redirect('#{admin_path}'); return false;"
10
+ options[:'data-no-turbolink'] = true
11
+ link_to(name, "https://#{shop.shopify_domain}/admin/#{admin_path}", options)
12
+ end
13
+
14
+ # Generate a link that will open its href in an embedded Shopify modal.
15
+ def link_to_modal(name, path, options = {})
16
+ modal_options = {
17
+ src: path,
18
+ title: options.delete(:modal_title),
19
+ width: options.delete(:modal_width),
20
+ height: options.delete(:modal_height),
21
+ buttons: options.delete(:modal_buttons),
22
+ }
23
+ options[:onclick] = "ShopifyApp.Modal.open(#{modal_options.to_json}); return false;"
24
+ options[:onclick].gsub!(/"function(.*?)"/, 'function\1')
25
+ link_to(name, path, options)
26
+ end
27
+
28
+ end
@@ -0,0 +1,3 @@
1
+ class DiscoApp::AppInstalledJob < DiscoApp::ShopJob
2
+ include DiscoApp::Concerns::AppInstalledJob
3
+ end
@@ -0,0 +1,3 @@
1
+ class DiscoApp::AppUninstalledJob < DiscoApp::ShopJob
2
+ include DiscoApp::Concerns::AppUninstalledJob
3
+ end
@@ -0,0 +1,22 @@
1
+ module DiscoApp::Concerns::AppInstalledJob
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ before_enqueue { @shop.awaiting_install! }
6
+ before_perform { @shop.installing! }
7
+ after_perform { @shop.installed! }
8
+ end
9
+
10
+ # Perform application installation.
11
+ #
12
+ # - Synchronise webhooks.
13
+ # - Synchronise carrier service, if required.
14
+ # - Perform initial update of shop information.
15
+ #
16
+ def perform(domain)
17
+ DiscoApp::SynchroniseWebhooksJob.perform_now(domain)
18
+ DiscoApp::SynchroniseCarrierServiceJob.perform_now(domain)
19
+ DiscoApp::ShopUpdateJob.perform_now(domain)
20
+ end
21
+
22
+ end
@@ -0,0 +1,20 @@
1
+ module DiscoApp::Concerns::AppUninstalledJob
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ before_enqueue { @shop.awaiting_uninstall! }
6
+ before_perform { @shop.uninstalling! }
7
+ after_perform { @shop.uninstalled! }
8
+ end
9
+
10
+ # Perform application uninstallation.
11
+ #
12
+ # - Mark charge status as "cancelled" unless charges have been waived.
13
+ #
14
+ def perform(domain, shop_data)
15
+ unless @shop.charge_waived?
16
+ @shop.charge_cancelled!
17
+ end
18
+ end
19
+
20
+ end
@@ -0,0 +1,16 @@
1
+ module DiscoApp::Concerns::ShopUpdateJob
2
+ extend ActiveSupport::Concern
3
+
4
+ # Perform an update of the current shop's information.
5
+ def perform(shopify_domain, shop_data = nil)
6
+ # If we weren't provided with shop data (eg from a webhook), fetch it.
7
+ shop_data ||= ActiveSupport::JSON::decode(ShopifyAPI::Shop.current.to_json)
8
+
9
+ # Ensure we can access shop data through symbols.
10
+ shop_data = HashWithIndifferentAccess.new(shop_data)
11
+
12
+ # Update model attributes present in both our model and the data hash.
13
+ @shop.update_attributes(shop_data.except(:id, :created_at).slice(*DiscoApp::Shop.column_names))
14
+ end
15
+
16
+ end
@@ -0,0 +1,51 @@
1
+ module DiscoApp::Concerns::SynchroniseCarrierServiceJob
2
+ extend ActiveSupport::Concern
3
+
4
+ # Ensure that any carrier service required by our app is registered.
5
+ def perform(shopify_domain)
6
+ # Don't proceed unless we have a name and callback url.
7
+ return unless carrier_service_name and callback_url
8
+
9
+ # Don't proceed if the carrier service is already registered.
10
+ return if current_carrier_service_names.include?(carrier_service_name)
11
+
12
+ # Otherwise, register the carrier service.
13
+ ShopifyAPI::CarrierService.create(
14
+ name: carrier_service_name,
15
+ callback_url: callback_url,
16
+ service_discovery: true,
17
+ format: 'json'
18
+ )
19
+
20
+ # De-activate and extraneous carrier services.
21
+ current_carrier_services.each do |carrier_service|
22
+ unless carrier_service.name == carrier_service_name
23
+ carrier_service.active = false
24
+ carrier_service.save
25
+ end
26
+ end
27
+ end
28
+
29
+ protected
30
+
31
+ def carrier_service_name
32
+ Rails.application.config.x.shopify_app_name
33
+ end
34
+
35
+ def callback_url
36
+ nil
37
+ end
38
+
39
+ private
40
+
41
+ # Return a list of currently registered callback URLs.
42
+ def current_carrier_service_names
43
+ current_carrier_services.map(&:name)
44
+ end
45
+
46
+ # Return a list of currently registered carrier services.
47
+ def current_carrier_services
48
+ @current_carrier_service ||= ShopifyAPI::CarrierService.find(:all)
49
+ end
50
+
51
+ end
@@ -0,0 +1,53 @@
1
+ module DiscoApp::Concerns::SynchroniseWebhooksJob
2
+ extend ActiveSupport::Concern
3
+
4
+ # Ensure the webhooks registered with our shop are the same as those listed
5
+ # in our application configuration.
6
+ def perform(shopify_domain)
7
+ # Get the full list of expected webhook topics.
8
+ expected_topics = [:'app/uninstalled', :'shop/update'] + topics
9
+
10
+ # Registered any webhooks that haven't been registered yet.
11
+ (expected_topics - current_topics).each do |topic|
12
+ ShopifyAPI::Webhook.create(
13
+ topic: topic,
14
+ address: webhooks_url,
15
+ format: 'json'
16
+ )
17
+ end
18
+
19
+ # Remove any extraneous topics.
20
+ current_webhooks.each do |webhook|
21
+ unless expected_topics.include?(webhook.topic.to_sym)
22
+ webhook.delete
23
+ end
24
+ end
25
+ end
26
+
27
+ protected
28
+
29
+ # Return a list of additional webhook topics to listen for. This method
30
+ # can be overridden in the application to provide a list of app-specific
31
+ # webhooks that should be created during synchronisation.
32
+ def topics
33
+ []
34
+ end
35
+
36
+ private
37
+
38
+ # Return a list of currently registered topics.
39
+ def current_topics
40
+ current_webhooks.map(&:topic).map(&:to_sym)
41
+ end
42
+
43
+ # Return a list of current registered webhooks.
44
+ def current_webhooks
45
+ @current_webhooks ||= ShopifyAPI::Webhook.find(:all)
46
+ end
47
+
48
+ # Return the absolute URL to the webhooks endpoint.
49
+ def webhooks_url
50
+ DiscoApp::Engine.routes.url_helpers.webhooks_url
51
+ end
52
+
53
+ end
@@ -0,0 +1,27 @@
1
+ # The base class for all jobs that should be performed in the context of a
2
+ # particular Shop's API session. The first argument to any job inheriting from
3
+ # this class must be the domain of the relevant store, so that the appropriate
4
+ # Shop model can be fetched and the temporary API session created.
5
+ class DiscoApp::ShopJob < ActiveJob::Base
6
+
7
+ queue_as :default
8
+
9
+ before_perform { |job| find_shop(job) }
10
+ before_enqueue { |job| find_shop(job) }
11
+
12
+ around_enqueue { |job, block| shop_context(job, block) }
13
+ around_perform { |job, block| shop_context(job, block) }
14
+
15
+ private
16
+
17
+ def find_shop(job)
18
+ @shop ||= DiscoApp::Shop.find_by!(shopify_domain: job.arguments.first)
19
+ end
20
+
21
+ def shop_context(job, block)
22
+ @shop.temp {
23
+ block.call(job.arguments)
24
+ }
25
+ end
26
+
27
+ end
@@ -0,0 +1,3 @@
1
+ class DiscoApp::ShopUpdateJob < DiscoApp::ShopJob
2
+ include DiscoApp::Concerns::ShopUpdateJob
3
+ end
@@ -0,0 +1,3 @@
1
+ class DiscoApp::SynchroniseCarrierServiceJob < DiscoApp::ShopJob
2
+ include DiscoApp::Concerns::SynchroniseCarrierServiceJob
3
+ end
@@ -0,0 +1,3 @@
1
+ class DiscoApp::SynchroniseWebhooksJob < DiscoApp::ShopJob
2
+ include DiscoApp::Concerns::SynchroniseWebhooksJob
3
+ end
@@ -0,0 +1,14 @@
1
+ module DiscoApp::Concerns::Plan
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+
6
+ has_many :subscriptions
7
+ has_many :shops, through: :subscriptions
8
+
9
+ enum status: [:available, :unavailable, :hidden]
10
+
11
+ scope :available, -> { where status: statuses[:available] }
12
+
13
+ end
14
+ end
@@ -0,0 +1,68 @@
1
+ module DiscoApp::Concerns::Shop
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ include ShopifyApp::Shop
6
+
7
+ # Define relationships to plans and subscriptions.
8
+ has_many :subscriptions
9
+ has_many :plans, through: :subscriptions
10
+
11
+ # Define possible installation statuses as an enum.
12
+ enum status: [:never_installed, :awaiting_install, :installing, :installed, :awaiting_uninstall, :uninstalling, :uninstalled]
13
+
14
+ # Define possible charge statuses as an enum.
15
+ enum charge_status: [:charge_none, :charge_pending, :charge_accepted, :charge_declined, :charge_active, :charge_cancelled, :charge_waived]
16
+
17
+ # Define some useful scopes.
18
+ scope :status, -> (status) { where status: status }
19
+ scope :installed, -> { where status: statuses[:installed] }
20
+ scope :has_active_shopify_plan, -> { where.not(plan_name: [:cancelled, :frozen, :fraudulent]) }
21
+
22
+ # Alias 'with_shopify_session' as 'temp', as per our existing conventions.
23
+ alias_method :temp, :with_shopify_session
24
+
25
+ # Return a hash of attributes that should be used to create a new charge for this shop.
26
+ # This method can be overridden by the inheriting Shop class in order to provide charges
27
+ # customised to a particular shop. Otherwise, the default settings configured in application.rb
28
+ # will be used.
29
+ def new_charge_attributes
30
+ {
31
+ type: Rails.configuration.x.shopify_charges_default_type,
32
+ name: Rails.configuration.x.shopify_app_name,
33
+ price: Rails.configuration.x.shopify_charges_default_price,
34
+ trial_days: Rails.configuration.x.shopify_charges_default_trial_days,
35
+ }
36
+ end
37
+
38
+ # Update this Shop's charge_status attribute based on the given Shopify charge object.
39
+ def update_charge_status(shopify_charge)
40
+ status_update_method_name = "charge_#{shopify_charge.status}!"
41
+ self.public_send(status_update_method_name) if self.respond_to? status_update_method_name
42
+ end
43
+
44
+ # Convenience method to get the currently active subscription for this Shop.
45
+ def current_subscription
46
+ subscriptions.active.first
47
+ end
48
+
49
+ # Return the absolute URL to the shop's storefront.
50
+ def url
51
+ "#{protocol}://#{domain}"
52
+ end
53
+
54
+ # Return the protocol the shop's storefront uses.
55
+ # @TODO: Consider when we may want to allow https: here. For now, automatic
56
+ # redirection should do most of the work.
57
+ def protocol
58
+ 'http'
59
+ end
60
+
61
+ # Return the absolute URL to the shop's admin.
62
+ def admin_url
63
+ "https://#{shopify_domain}/admin"
64
+ end
65
+
66
+ end
67
+
68
+ end
@@ -0,0 +1,14 @@
1
+ module DiscoApp::Concerns::Subscription
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+
6
+ belongs_to :shop
7
+ belongs_to :plan
8
+
9
+ enum status: [:active, :replaced, :cancelled]
10
+
11
+ scope :active, -> { where status: statuses[:active] }
12
+
13
+ end
14
+ end
@@ -0,0 +1,3 @@
1
+ class DiscoApp::Plan < ActiveRecord::Base
2
+ include DiscoApp::Concerns::Plan
3
+ end
@@ -0,0 +1,18 @@
1
+ module DiscoApp
2
+ class SessionStorage
3
+ def self.store(session)
4
+ shop = Shop.find_or_initialize_by(shopify_domain: session.url)
5
+ shop.shopify_token = session.token
6
+ shop.save!
7
+ shop.id
8
+ end
9
+
10
+ def self.retrieve(id)
11
+ return unless id
12
+ shop = Shop.find(id)
13
+ ShopifyAPI::Session.new(shop.shopify_domain, shop.shopify_token)
14
+ rescue ActiveRecord::RecordNotFound
15
+ nil
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,3 @@
1
+ class DiscoApp::Shop < ActiveRecord::Base
2
+ include DiscoApp::Concerns::Shop
3
+ end
@@ -0,0 +1,3 @@
1
+ class DiscoApp::Subscription < ActiveRecord::Base
2
+ include DiscoApp::Concerns::Subscription
3
+ end
@@ -0,0 +1,73 @@
1
+ module DiscoApp
2
+ class ChargesService
3
+
4
+ # Create a new charge for the given Shop using the Shopify API.
5
+ #
6
+ # The attributes of the charge are fetched using the shop's `new_charge_attributes` method, which can be overriden
7
+ # to provide custom charge types for individual shops.
8
+ #
9
+ # Returns the new Shopify charge model on success, nil otherwise.
10
+ def self.create(shop)
11
+ shopify_charge = shop.temp {
12
+ self.charge_api_class(shop).create(self.new_charge_attributes(shop))
13
+ }
14
+
15
+ # If the charge was successfully created, update the charge status on the shop.
16
+ shop.update_charge_status(shopify_charge) if shopify_charge
17
+
18
+ # Return the charge.
19
+ shopify_charge
20
+ end
21
+
22
+ # Fetch the specified charge for the given Shop using the Shopify API and check that it has been actioned (either
23
+ # accepted or declined). Updates the shop object's charge status, then returns the charge if it was accepted or
24
+ # nil otherwise.
25
+ def self.get_accepted_charge(shop, charge_id)
26
+ begin
27
+ shopify_charge = shop.temp {
28
+ self.charge_api_class(shop).find(charge_id)
29
+ }
30
+
31
+ # If the charge was successfully fetched, update the status for the shop accordingly.
32
+ shop.update_charge_status(shopify_charge) if shopify_charge
33
+
34
+ shopify_charge
35
+ rescue
36
+ nil
37
+ end
38
+ end
39
+
40
+ # Attempt to activate the given Shopify charge for the given Shop using the Shopify API.
41
+ # Returns true on successful activation, false otherwise.
42
+ def self.activate(shop, shopify_charge)
43
+ begin
44
+ shop.temp {
45
+ shopify_charge.activate
46
+ }
47
+ shop.charge_active!
48
+ true
49
+ rescue
50
+ false
51
+ end
52
+ end
53
+
54
+ # Merge the new_charge_attributes returned by the given shop model and merge them with some application-level
55
+ # charge attributes.
56
+ def self.new_charge_attributes(shop)
57
+ shop.new_charge_attributes.merge(
58
+ return_url: DiscoApp::Engine.routes.url_helpers.activate_charge_url,
59
+ test: !Rails.configuration.x.shopify_charges_real,
60
+ )
61
+ end
62
+
63
+ # Get the appropriate Shopify API class for the given shop (either ApplicationCharge or RecurringApplicationCharge).
64
+ def self.charge_api_class(shop)
65
+ if shop.new_charge_attributes[:type] == :one_time
66
+ ShopifyAPI::ApplicationCharge
67
+ else
68
+ ShopifyAPI::RecurringApplicationCharge
69
+ end
70
+ end
71
+
72
+ end
73
+ end
@@ -0,0 +1,25 @@
1
+ class DiscoApp::SubscriptionService
2
+
3
+ # Subscribe the given shop to the given plan.
4
+ def self.subscribe(shop, plan)
5
+ # Mark all existing active subscriptions as replaced.
6
+ shop.subscriptions.active.update_all(status: DiscoApp::Subscription.statuses[:replaced])
7
+
8
+ # Add the new subscription.
9
+ DiscoApp::Subscription.create!(
10
+ shop: shop,
11
+ plan: plan,
12
+ status: DiscoApp::Subscription.statuses[:active],
13
+ name: plan.name,
14
+ charge_type: plan.charge_type,
15
+ price: plan.default_price,
16
+ trial_days: plan.default_trial_days
17
+ )
18
+ end
19
+
20
+ # Cancel any active subscription for the given shop.
21
+ def self.cancel(shop)
22
+ shop.subscriptions.active.update_all(status: DiscoApp::Subscription.statuses[:cancelled])
23
+ end
24
+
25
+ end
@@ -0,0 +1,30 @@
1
+ class DiscoApp::WebhookService
2
+
3
+ # Return true iff the provided hmac_to_verify matches that calculated from the
4
+ # give data and secret.
5
+ def self.is_valid_hmac?(body, secret, hmac_to_verify)
6
+ self.calculated_hmac(body, secret) == hmac_to_verify
7
+ end
8
+
9
+ # Calculate the HMAC for the given data and secret.
10
+ def self.calculated_hmac(body, secret)
11
+ digest = OpenSSL::Digest.new('sha256')
12
+ Base64.encode64(OpenSSL::HMAC.digest(digest, secret, body)).strip
13
+ end
14
+
15
+ # Try to find a job class for the given webhook topic.
16
+ def self.find_job_class(topic)
17
+ begin
18
+ # First try to find a top-level matching job class.
19
+ "#{topic}_job".gsub('/', '_').classify.constantize
20
+ rescue NameError
21
+ # If that fails, try to find a DiscoApp:: prefixed job class.
22
+ begin
23
+ %Q{DiscoApp::#{"#{topic}_job".gsub('/', '_').classify}}.constantize
24
+ rescue NameError
25
+ nil
26
+ end
27
+ end
28
+ end
29
+
30
+ end
@@ -0,0 +1 @@
1
+ activate_charge
@@ -0,0 +1 @@
1
+ create_charge