disco_app 0.8.3
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.
- checksums.yaml +7 -0
- data/Rakefile +37 -0
- data/app/assets/images/disco_app/icon.svg +1 -0
- data/app/assets/javascripts/disco_app/components/shopify_admin_link.js.jsx +29 -0
- data/app/assets/javascripts/disco_app/components.js +5 -0
- data/app/assets/javascripts/disco_app/disco_app.js +7 -0
- data/app/assets/javascripts/disco_app/frame.js +152 -0
- data/app/assets/javascripts/disco_app/shopify-turbolinks.js +7 -0
- data/app/assets/stylesheets/disco_app/bootstrap/_custom.scss +54 -0
- data/app/assets/stylesheets/disco_app/bootstrap/_variables.scss +872 -0
- data/app/assets/stylesheets/disco_app/disco/_buttons.scss +31 -0
- data/app/assets/stylesheets/disco_app/disco/_cards.scss +51 -0
- data/app/assets/stylesheets/disco_app/disco/_forms.scss +23 -0
- data/app/assets/stylesheets/disco_app/disco/_grid.scss +58 -0
- data/app/assets/stylesheets/disco_app/disco/_sections.scss +61 -0
- data/app/assets/stylesheets/disco_app/disco/_tables.scss +57 -0
- data/app/assets/stylesheets/disco_app/disco/_tabs.scss +61 -0
- data/app/assets/stylesheets/disco_app/disco/_type.scss +39 -0
- data/app/assets/stylesheets/disco_app/disco/mixins/_flexbox.scss +394 -0
- data/app/assets/stylesheets/disco_app/disco_app.scss +16 -0
- data/app/assets/stylesheets/disco_app/frame/_buttons.scss +54 -0
- data/app/assets/stylesheets/disco_app/frame/_forms.scss +26 -0
- data/app/assets/stylesheets/disco_app/frame/_layout.scss +77 -0
- data/app/assets/stylesheets/disco_app/frame/_type.scss +32 -0
- data/app/assets/stylesheets/disco_app/frame.scss +9 -0
- data/app/controllers/disco_app/app_proxy_controller.rb +41 -0
- data/app/controllers/disco_app/authenticated_controller.rb +44 -0
- data/app/controllers/disco_app/carrier_request_controller.rb +28 -0
- data/app/controllers/disco_app/charges_controller.rb +30 -0
- data/app/controllers/disco_app/frame_controller.rb +9 -0
- data/app/controllers/disco_app/install_controller.rb +26 -0
- data/app/controllers/disco_app/webhooks_controller.rb +42 -0
- data/app/helpers/disco_app/application_helper.rb +28 -0
- data/app/jobs/disco_app/app_installed_job.rb +3 -0
- data/app/jobs/disco_app/app_uninstalled_job.rb +3 -0
- data/app/jobs/disco_app/concerns/app_installed_job.rb +22 -0
- data/app/jobs/disco_app/concerns/app_uninstalled_job.rb +23 -0
- data/app/jobs/disco_app/concerns/shop_update_job.rb +16 -0
- data/app/jobs/disco_app/concerns/synchronise_carrier_service_job.rb +52 -0
- data/app/jobs/disco_app/concerns/synchronise_webhooks_job.rb +61 -0
- data/app/jobs/disco_app/shop_job.rb +27 -0
- data/app/jobs/disco_app/shop_update_job.rb +3 -0
- data/app/jobs/disco_app/synchronise_carrier_service_job.rb +3 -0
- data/app/jobs/disco_app/synchronise_webhooks_job.rb +3 -0
- data/app/models/disco_app/concerns/plan.rb +14 -0
- data/app/models/disco_app/concerns/shop.rb +70 -0
- data/app/models/disco_app/concerns/subscription.rb +14 -0
- data/app/models/disco_app/plan.rb +3 -0
- data/app/models/disco_app/session_storage.rb +18 -0
- data/app/models/disco_app/shop.rb +3 -0
- data/app/models/disco_app/subscription.rb +3 -0
- data/app/services/disco_app/charges_service.rb +73 -0
- data/app/services/disco_app/subscription_service.rb +25 -0
- data/app/services/disco_app/webhook_service.rb +30 -0
- data/app/views/disco_app/charges/activate.html.erb +1 -0
- data/app/views/disco_app/charges/create.html.erb +1 -0
- data/app/views/disco_app/charges/new.html.erb +45 -0
- data/app/views/disco_app/frame/frame.html.erb +36 -0
- data/app/views/disco_app/install/installing.html.erb +7 -0
- data/app/views/disco_app/install/uninstalling.html.erb +1 -0
- data/app/views/disco_app/proxy_errors/404.html.erb +1 -0
- data/app/views/disco_app/shared/_card.html.erb +14 -0
- data/app/views/disco_app/shared/_section.html.erb +17 -0
- data/app/views/layouts/application.html.erb +18 -0
- data/app/views/layouts/embedded_app.html.erb +41 -0
- data/app/views/layouts/embedded_app_modal.html.erb +17 -0
- data/app/views/sessions/new.html.erb +26 -0
- data/config/routes.rb +26 -0
- data/db/migrate/20150525000000_create_shops_if_not_existent.rb +15 -0
- data/db/migrate/20150525162112_add_status_to_shops.rb +5 -0
- data/db/migrate/20150525171422_add_meta_to_shops.rb +11 -0
- data/db/migrate/20150629210346_add_charge_status_to_shop.rb +5 -0
- data/db/migrate/20150814214025_add_more_meta_to_shops.rb +15 -0
- data/db/migrate/20151017231302_create_disco_app_plans.rb +13 -0
- data/db/migrate/20151017232027_create_disco_app_subscriptions.rb +15 -0
- data/db/migrate/20151017234409_move_shop_to_disco_app_engine.rb +5 -0
- data/db/migrate/20160112233706_create_disco_app_sessions.rb +12 -0
- data/db/migrate/20160113194418_add_shop_id_to_disco_app_sessions.rb +6 -0
- data/lib/disco_app/engine.rb +25 -0
- data/lib/disco_app/session.rb +14 -0
- data/lib/disco_app/support/file_fixtures.rb +23 -0
- data/lib/disco_app/test_help.rb +11 -0
- data/lib/disco_app/version.rb +3 -0
- data/lib/disco_app.rb +4 -0
- data/lib/generators/disco_app/USAGE +5 -0
- data/lib/generators/disco_app/disco_app_generator.rb +170 -0
- data/lib/generators/disco_app/mailify/mailify_generator.rb +54 -0
- data/lib/generators/disco_app/monitorify/monitorify_generator.rb +28 -0
- data/lib/generators/disco_app/monitorify/templates/config/newrelic.yml +26 -0
- data/lib/generators/disco_app/monitorify/templates/initializers/rollbar.rb +12 -0
- data/lib/generators/disco_app/reactify/reactify_generator.rb +45 -0
- data/lib/generators/disco_app/templates/assets/javascripts/application.js +17 -0
- data/lib/generators/disco_app/templates/assets/stylesheets/application.scss +5 -0
- data/lib/generators/disco_app/templates/config/puma.rb +15 -0
- data/lib/generators/disco_app/templates/controllers/home_controller.rb +7 -0
- data/lib/generators/disco_app/templates/initializers/disco_app.rb +1 -0
- data/lib/generators/disco_app/templates/initializers/session_store.rb +2 -0
- data/lib/generators/disco_app/templates/initializers/shopify_app.rb +7 -0
- data/lib/generators/disco_app/templates/initializers/shopify_session_repository.rb +7 -0
- data/lib/generators/disco_app/templates/root/Procfile +2 -0
- data/lib/generators/disco_app/templates/views/home/index.html.erb +2 -0
- data/lib/tasks/carrier_service.rake +10 -0
- data/lib/tasks/sessions.rake +9 -0
- data/lib/tasks/start.rake +3 -0
- data/lib/tasks/webhooks.rake +10 -0
- data/test/controllers/disco_app/install_controller_test.rb +50 -0
- data/test/controllers/disco_app/webhooks_controller_test.rb +58 -0
- data/test/controllers/home_controller_test.rb +61 -0
- data/test/disco_app_test.rb +7 -0
- data/test/dummy/Rakefile +6 -0
- data/test/dummy/app/assets/javascripts/application.js +17 -0
- data/test/dummy/app/assets/stylesheets/application.scss +5 -0
- data/test/dummy/app/controllers/application_controller.rb +6 -0
- data/test/dummy/app/controllers/home_controller.rb +7 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/jobs/disco_app/app_uninstalled_job.rb +11 -0
- data/test/dummy/app/models/disco_app/shop.rb +15 -0
- data/test/dummy/app/views/home/index.html.erb +2 -0
- data/test/dummy/bin/bundle +3 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +29 -0
- data/test/dummy/config/application.rb +41 -0
- data/test/dummy/config/boot.rb +5 -0
- data/test/dummy/config/database.yml +25 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +41 -0
- data/test/dummy/config/environments/production.rb +85 -0
- data/test/dummy/config/environments/test.rb +42 -0
- data/test/dummy/config/initializers/assets.rb +11 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/test/dummy/config/initializers/cookies_serializer.rb +3 -0
- data/test/dummy/config/initializers/disco_app.rb +1 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/initializers/mime_types.rb +4 -0
- data/test/dummy/config/initializers/omniauth.rb +9 -0
- data/test/dummy/config/initializers/session_store.rb +2 -0
- data/test/dummy/config/initializers/shopify_app.rb +7 -0
- data/test/dummy/config/initializers/shopify_session_repository.rb +7 -0
- data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/test/dummy/config/locales/en.yml +23 -0
- data/test/dummy/config/routes.rb +8 -0
- data/test/dummy/config/secrets.yml +22 -0
- data/test/dummy/config.ru +4 -0
- data/test/dummy/db/schema.rb +81 -0
- data/test/dummy/public/404.html +67 -0
- data/test/dummy/public/422.html +67 -0
- data/test/dummy/public/500.html +66 -0
- data/test/dummy/public/favicon.ico +0 -0
- data/test/fixtures/api/widget_store/shop.json +46 -0
- data/test/fixtures/api/widget_store/webhooks.json +1 -0
- data/test/fixtures/disco_app/plans.yml +32 -0
- data/test/fixtures/disco_app/shops.yml +10 -0
- data/test/fixtures/disco_app/subscriptions.yml +26 -0
- data/test/fixtures/webhooks/app_uninstalled.json +46 -0
- data/test/integration/navigation_test.rb +10 -0
- data/test/jobs/disco_app/app_installed_job_test.rb +30 -0
- data/test/jobs/disco_app/app_uninstalled_job_test.rb +32 -0
- data/test/models/disco_app/plan_test.rb +5 -0
- data/test/models/disco_app/session_test.rb +31 -0
- data/test/models/disco_app/shop_test.rb +26 -0
- data/test/models/disco_app/subscription_test.rb +6 -0
- data/test/services/disco_app/subscription_service_test.rb +28 -0
- data/test/support/test_file_fixtures.rb +29 -0
- data/test/test_helper.rb +51 -0
- metadata +508 -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.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,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,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,23 @@
|
|
|
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
|
+
# - Remove any stored sessions for the shop.
|
|
14
|
+
#
|
|
15
|
+
def perform(domain, shop_data)
|
|
16
|
+
unless @shop.charge_waived?
|
|
17
|
+
@shop.charge_cancelled!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@shop.sessions.delete_all
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
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,52 @@
|
|
|
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
|
+
# Registered the carrier service if it hasn't been registered yet.
|
|
10
|
+
unless current_carrier_service_names.include?(carrier_service_name)
|
|
11
|
+
ShopifyAPI::CarrierService.create(
|
|
12
|
+
name: carrier_service_name,
|
|
13
|
+
callback_url: callback_url,
|
|
14
|
+
service_discovery: true,
|
|
15
|
+
format: :json
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Ensure any existing carrier services (with the correct name) are active
|
|
20
|
+
# and have a current callback URL.
|
|
21
|
+
current_carrier_services.each do |carrier_service|
|
|
22
|
+
if carrier_service.name == carrier_service_name
|
|
23
|
+
carrier_service.callback_url = callback_url
|
|
24
|
+
carrier_service.active = true
|
|
25
|
+
carrier_service.save
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
protected
|
|
31
|
+
|
|
32
|
+
def carrier_service_name
|
|
33
|
+
Rails.application.config.x.shopify_app_name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def callback_url
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Return a list of currently registered carrier service names.
|
|
43
|
+
def current_carrier_service_names
|
|
44
|
+
current_carrier_services.map(&:name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Return a list of currently registered carrier services.
|
|
48
|
+
def current_carrier_services
|
|
49
|
+
@current_carrier_service ||= ShopifyAPI::CarrierService.find(:all)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
|
|
26
|
+
# Ensure webhook addresses are current.
|
|
27
|
+
current_webhooks.each do |webhook|
|
|
28
|
+
unless webhook.address == webhooks_url
|
|
29
|
+
webhook.address = webhooks_url
|
|
30
|
+
webhook.save
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
protected
|
|
36
|
+
|
|
37
|
+
# Return a list of additional webhook topics to listen for. This method
|
|
38
|
+
# can be overridden in the application to provide a list of app-specific
|
|
39
|
+
# webhooks that should be created during synchronisation.
|
|
40
|
+
def topics
|
|
41
|
+
[]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Return a list of currently registered topics.
|
|
47
|
+
def current_topics
|
|
48
|
+
current_webhooks.map(&:topic).map(&:to_sym)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Return a list of current registered webhooks.
|
|
52
|
+
def current_webhooks
|
|
53
|
+
@current_webhooks ||= ShopifyAPI::Webhook.find(:all)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Return the absolute URL to the webhooks endpoint.
|
|
57
|
+
def webhooks_url
|
|
58
|
+
DiscoApp::Engine.routes.url_helpers.webhooks_url
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
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,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,70 @@
|
|
|
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 relationship to sessions.
|
|
12
|
+
has_many :sessions, class_name: 'DiscoApp::Session', dependent: :destroy
|
|
13
|
+
|
|
14
|
+
# Define possible installation statuses as an enum.
|
|
15
|
+
enum status: [:never_installed, :awaiting_install, :installing, :installed, :awaiting_uninstall, :uninstalling, :uninstalled]
|
|
16
|
+
|
|
17
|
+
# Define possible charge statuses as an enum.
|
|
18
|
+
enum charge_status: [:charge_none, :charge_pending, :charge_accepted, :charge_declined, :charge_active, :charge_cancelled, :charge_waived]
|
|
19
|
+
|
|
20
|
+
# Define some useful scopes.
|
|
21
|
+
scope :status, -> (status) { where status: status }
|
|
22
|
+
scope :installed, -> { where status: statuses[:installed] }
|
|
23
|
+
scope :has_active_shopify_plan, -> { where.not(plan_name: [:cancelled, :frozen, :fraudulent]) }
|
|
24
|
+
|
|
25
|
+
# Alias 'with_shopify_session' as 'temp', as per our existing conventions.
|
|
26
|
+
alias_method :temp, :with_shopify_session
|
|
27
|
+
|
|
28
|
+
# Return a hash of attributes that should be used to create a new charge for this shop.
|
|
29
|
+
# This method can be overridden by the inheriting Shop class in order to provide charges
|
|
30
|
+
# customised to a particular shop. Otherwise, the default settings configured in application.rb
|
|
31
|
+
# will be used.
|
|
32
|
+
def new_charge_attributes
|
|
33
|
+
{
|
|
34
|
+
type: Rails.configuration.x.shopify_charges_default_type,
|
|
35
|
+
name: Rails.configuration.x.shopify_app_name,
|
|
36
|
+
price: Rails.configuration.x.shopify_charges_default_price,
|
|
37
|
+
trial_days: Rails.configuration.x.shopify_charges_default_trial_days,
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Update this Shop's charge_status attribute based on the given Shopify charge object.
|
|
42
|
+
def update_charge_status(shopify_charge)
|
|
43
|
+
status_update_method_name = "charge_#{shopify_charge.status}!"
|
|
44
|
+
self.public_send(status_update_method_name) if self.respond_to? status_update_method_name
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Convenience method to get the currently active subscription for this Shop.
|
|
48
|
+
def current_subscription
|
|
49
|
+
subscriptions.active.first
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Return the absolute URL to the shop's storefront.
|
|
53
|
+
def url
|
|
54
|
+
"#{protocol}://#{domain}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Return the protocol the shop's storefront uses. This should now always be
|
|
58
|
+
# https as all Shopify stores have SSL enabled.
|
|
59
|
+
def protocol
|
|
60
|
+
'https'
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Return the absolute URL to the shop's admin.
|
|
64
|
+
def admin_url
|
|
65
|
+
"https://#{shopify_domain}/admin"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
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,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,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
|