shopify-gold 2.0.0.pre
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/README.md +144 -0
- data/Rakefile +32 -0
- data/app/assets/config/gold_manifest.js +0 -0
- data/app/assets/images/gold/app-logo.svg +17 -0
- data/app/assets/images/gold/aside-bg.svg +54 -0
- data/app/assets/javascripts/gold/billing.js +30 -0
- data/app/assets/stylesheets/gold/billing.sass +64 -0
- data/app/assets/stylesheets/shopify/polaris.css +1 -0
- data/app/controllers/gold/admin/billing_controller.rb +190 -0
- data/app/controllers/gold/application_controller.rb +17 -0
- data/app/controllers/gold/authenticated_controller.rb +15 -0
- data/app/controllers/gold/billing_controller.rb +185 -0
- data/app/controllers/gold/concerns/merchant_facing.rb +85 -0
- data/app/jobs/app_uninstalled_job.rb +2 -0
- data/app/jobs/gold/after_authenticate_job.rb +36 -0
- data/app/jobs/gold/app_uninstalled_job.rb +10 -0
- data/app/jobs/gold/application_job.rb +20 -0
- data/app/mailers/gold/application_mailer.rb +7 -0
- data/app/mailers/gold/billing_mailer.rb +36 -0
- data/app/models/gold/billing.rb +157 -0
- data/app/models/gold/concerns/gilded.rb +22 -0
- data/app/models/gold/machine.rb +301 -0
- data/app/models/gold/shopify_plan.rb +70 -0
- data/app/models/gold/tier.rb +97 -0
- data/app/models/gold/transition.rb +26 -0
- data/app/operations/gold/accept_or_decline_charge_op.rb +119 -0
- data/app/operations/gold/accept_or_decline_terms_op.rb +26 -0
- data/app/operations/gold/apply_discount_op.rb +32 -0
- data/app/operations/gold/apply_tier_op.rb +51 -0
- data/app/operations/gold/charge_op.rb +99 -0
- data/app/operations/gold/check_charge_op.rb +50 -0
- data/app/operations/gold/cleanup_op.rb +20 -0
- data/app/operations/gold/convert_affiliate_to_paid_op.rb +32 -0
- data/app/operations/gold/freeze_op.rb +15 -0
- data/app/operations/gold/install_op.rb +30 -0
- data/app/operations/gold/issue_credit_op.rb +55 -0
- data/app/operations/gold/mark_as_delinquent_op.rb +21 -0
- data/app/operations/gold/resolve_outstanding_charge_op.rb +90 -0
- data/app/operations/gold/select_tier_op.rb +58 -0
- data/app/operations/gold/suspend_op.rb +21 -0
- data/app/operations/gold/uninstall_op.rb +20 -0
- data/app/operations/gold/unsuspend_op.rb +20 -0
- data/app/views/gold/admin/billing/_active_charge.erb +29 -0
- data/app/views/gold/admin/billing/_credit.erb +46 -0
- data/app/views/gold/admin/billing/_discount.erb +23 -0
- data/app/views/gold/admin/billing/_history.erb +65 -0
- data/app/views/gold/admin/billing/_overview.erb +14 -0
- data/app/views/gold/admin/billing/_shopify_plan.erb +21 -0
- data/app/views/gold/admin/billing/_state.erb +26 -0
- data/app/views/gold/admin/billing/_status.erb +25 -0
- data/app/views/gold/admin/billing/_tier.erb +155 -0
- data/app/views/gold/admin/billing/_trial_days.erb +42 -0
- data/app/views/gold/billing/_inner_head.html.erb +1 -0
- data/app/views/gold/billing/declined_charge.html.erb +20 -0
- data/app/views/gold/billing/expired_charge.html.erb +14 -0
- data/app/views/gold/billing/missing_charge.html.erb +22 -0
- data/app/views/gold/billing/outstanding_charge.html.erb +12 -0
- data/app/views/gold/billing/suspended.html.erb +8 -0
- data/app/views/gold/billing/terms.html.erb +22 -0
- data/app/views/gold/billing/tier.html.erb +29 -0
- data/app/views/gold/billing/transition_error.html.erb +9 -0
- data/app/views/gold/billing/unavailable.erb +8 -0
- data/app/views/gold/billing/uninstalled.html.erb +13 -0
- data/app/views/gold/billing_mailer/affiliate_to_paid.erb +23 -0
- data/app/views/gold/billing_mailer/delinquent.html.erb +21 -0
- data/app/views/gold/billing_mailer/suspension.html.erb +19 -0
- data/app/views/layouts/gold/billing.html.erb +22 -0
- data/app/views/layouts/gold/mailer.html.erb +13 -0
- data/app/views/layouts/gold/mailer.text.erb +1 -0
- data/config/routes.rb +33 -0
- data/db/migrate/01_create_gold_billing.rb +11 -0
- data/db/migrate/02_create_gold_transitions.rb +29 -0
- data/db/migrate/03_add_foreign_key_to_billing.rb +8 -0
- data/lib/gold/admin_engine.rb +8 -0
- data/lib/gold/billing_migrator.rb +107 -0
- data/lib/gold/coverage.rb +89 -0
- data/lib/gold/diagram.rb +40 -0
- data/lib/gold/engine.rb +16 -0
- data/lib/gold/exceptions/metadata_missing.rb +5 -0
- data/lib/gold/outcomes.rb +77 -0
- data/lib/gold/retries.rb +31 -0
- data/lib/gold/version.rb +3 -0
- data/lib/gold.rb +102 -0
- data/lib/tasks/gold_tasks.rake +94 -0
- metadata +298 -0
@@ -0,0 +1,190 @@
|
|
1
|
+
module Gold
|
2
|
+
module Admin
|
3
|
+
# Main controller for Gold to be used in app
|
4
|
+
class BillingController < ApplicationController
|
5
|
+
credentials = Gold.configuration.admin_credentials
|
6
|
+
http_basic_authenticate_with name: credentials[:user],
|
7
|
+
password: credentials[:password]
|
8
|
+
|
9
|
+
around_action :shopify_session
|
10
|
+
|
11
|
+
rescue_from Statesman::TransitionFailedError,
|
12
|
+
Statesman::GuardFailedError,
|
13
|
+
Gold::Exceptions::MetadataMissing do |e|
|
14
|
+
flash[:danger] = e.message
|
15
|
+
go_back
|
16
|
+
end
|
17
|
+
|
18
|
+
def transition
|
19
|
+
transitions = [params[:to]].flatten
|
20
|
+
|
21
|
+
transitions.each do |t|
|
22
|
+
billing.transition_to!(t, metadata)
|
23
|
+
end
|
24
|
+
|
25
|
+
flash[:success] = "Transitioned to '#{transitions.join(',')}'"
|
26
|
+
go_back
|
27
|
+
end
|
28
|
+
|
29
|
+
# rubocop:disable Metrics/LineLength:Length
|
30
|
+
def change_tier
|
31
|
+
tier = Tier.find(params[:tier])
|
32
|
+
|
33
|
+
outcome = SelectTierOp.new(billing, tier).call
|
34
|
+
|
35
|
+
if outcome.ok?
|
36
|
+
charge_outcome = ChargeOp.new(billing, process_charge_url).call
|
37
|
+
|
38
|
+
if charge_outcome.is_a?(Outcomes::ActiveCharge)
|
39
|
+
accept_or_decline_outcome = AcceptOrDeclineChargeOp.new(billing, charge_outcome.charge_id).call
|
40
|
+
|
41
|
+
if accept_or_decline_outcome.ok?
|
42
|
+
apply_tier_outcome = ApplyTierOp.new(billing).call
|
43
|
+
|
44
|
+
if apply_tier_outcome.ok? # rubocop:disable Metrics/BlockNesting
|
45
|
+
flash[:success] = "Changed billing to '#{tier.name}', no merchant action required"
|
46
|
+
else
|
47
|
+
flash[:danger] = "Failed to apply tier because '#{apply_tier_outcome.reason}'"
|
48
|
+
end
|
49
|
+
else
|
50
|
+
flash[:danger] = "Failed to accept or decline charge because '#{accept_or_decline_outcome.reason}'"
|
51
|
+
end
|
52
|
+
elsif charge_outcome.is_a?(Outcomes::ChargeNotNeeded)
|
53
|
+
flash[:success] = "Applied free tier '#{tier.name}', no merchant action required. You may need to cancel their current charge."
|
54
|
+
else
|
55
|
+
flash[:warning] = "Changing tier, but merchant action is required before tier is applied"
|
56
|
+
end
|
57
|
+
else
|
58
|
+
flash[:danger] = "Failed to apply tier because '#{outcome.reason}'"
|
59
|
+
end
|
60
|
+
|
61
|
+
go_back
|
62
|
+
end
|
63
|
+
# rubocop:enable Metrics/LineLength:Length
|
64
|
+
|
65
|
+
# Issue a credit to a shop
|
66
|
+
def issue_credit
|
67
|
+
outcome = IssueCreditOp.new(billing, params[:amount], params[:reason]).call
|
68
|
+
|
69
|
+
if outcome.ok?
|
70
|
+
flash["success"] = "Issued credit"
|
71
|
+
else
|
72
|
+
flash["danger"] = "Failed to issue credit because '#{outcome.message}'"
|
73
|
+
end
|
74
|
+
|
75
|
+
go_back
|
76
|
+
end
|
77
|
+
|
78
|
+
def cancel_charge
|
79
|
+
begin
|
80
|
+
charge = ShopifyAPI::RecurringApplicationCharge.find(params[:charge_id])
|
81
|
+
charge.cancel
|
82
|
+
flash["success"] = "Cancelled charge"
|
83
|
+
rescue ActiveResource::ResourceNotFound
|
84
|
+
flash["warning"] = "That charge was not found for '#{ShopifyAPI::Base.site}'"
|
85
|
+
end
|
86
|
+
|
87
|
+
go_back
|
88
|
+
end
|
89
|
+
|
90
|
+
# Suspend a shop
|
91
|
+
def suspend
|
92
|
+
if params[:confirm_suspend].blank?
|
93
|
+
flash[:danger] = "Please confirm you want to suspend this account"
|
94
|
+
go_back && return
|
95
|
+
end
|
96
|
+
|
97
|
+
SuspendOp.new(billing).call
|
98
|
+
flash[:success] = "Account suspended"
|
99
|
+
go_back
|
100
|
+
end
|
101
|
+
|
102
|
+
# Unsuspend a shop
|
103
|
+
def unsuspend
|
104
|
+
UnsuspendOp.new(billing).call
|
105
|
+
flash[:success] = "Account unsuspended"
|
106
|
+
go_back
|
107
|
+
end
|
108
|
+
|
109
|
+
def apply_discount
|
110
|
+
outcome = ApplyDiscountOp.new(billing,
|
111
|
+
params[:percentage].to_i,
|
112
|
+
process_charge_url).call
|
113
|
+
|
114
|
+
if outcome.ok?
|
115
|
+
flash[:warning] = "Discount applied, merchant must reauthorize the charge"
|
116
|
+
else
|
117
|
+
flash[:danger] = "Failed to apply discount because '#{outcome.message}'"
|
118
|
+
end
|
119
|
+
|
120
|
+
go_back
|
121
|
+
end
|
122
|
+
|
123
|
+
def override_shopify_plan
|
124
|
+
plan = params[:plan].presence
|
125
|
+
if billing.update(shopify_plan_override: plan)
|
126
|
+
flash[:success] = if plan
|
127
|
+
"Changed Shopify plan to '#{plan}'"
|
128
|
+
else
|
129
|
+
"Removed Shopify plan override"
|
130
|
+
end
|
131
|
+
else
|
132
|
+
flash[:danger] = "Failed to save shop"
|
133
|
+
end
|
134
|
+
|
135
|
+
go_back
|
136
|
+
end
|
137
|
+
|
138
|
+
def reset_trial_days
|
139
|
+
case params[:trial_action]
|
140
|
+
when "reset"
|
141
|
+
if billing.update(trial_starts_at: Time.current)
|
142
|
+
flash[:success] = "New trial starts... now! You might need to" \
|
143
|
+
"cancel their charge first."
|
144
|
+
else
|
145
|
+
flash[:danger] = "Failed to reset trial days"
|
146
|
+
end
|
147
|
+
when "clear"
|
148
|
+
if billing.update(trial_starts_at: nil)
|
149
|
+
flash[:success] = "Trial started over. The merchant will be faced" \
|
150
|
+
"with a new charge upon login."
|
151
|
+
else
|
152
|
+
flash[:danger] = "Failed to clear trial days"
|
153
|
+
end
|
154
|
+
else
|
155
|
+
flash[:danger] = "Don't know what to do with" \
|
156
|
+
" '#{params[:trial_action]}' trial action"
|
157
|
+
end
|
158
|
+
|
159
|
+
go_back
|
160
|
+
end
|
161
|
+
|
162
|
+
private
|
163
|
+
|
164
|
+
def shopify_session
|
165
|
+
billing.shop.with_shopify_session do
|
166
|
+
yield
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def billing
|
171
|
+
@billing ||= Billing.find(params[:id])
|
172
|
+
end
|
173
|
+
|
174
|
+
def metadata
|
175
|
+
md = {
|
176
|
+
applied_by: "admin"
|
177
|
+
}
|
178
|
+
|
179
|
+
if params.dig(:metadata, :key).present?
|
180
|
+
md[params[:metadata][:key]] = params[:metadata][:value]
|
181
|
+
end
|
182
|
+
md
|
183
|
+
end
|
184
|
+
|
185
|
+
def go_back
|
186
|
+
redirect_back(fallback_location: main_app.gold_admin_engine_path)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Gold
|
2
|
+
# Controller that all Gold controllers inherit from.
|
3
|
+
class ApplicationController < ActionController::Base
|
4
|
+
protect_from_forgery with: :exception
|
5
|
+
around_action :catch_halt
|
6
|
+
|
7
|
+
layout "application"
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
# Allows an action to stop processing (like `return`, but works even in
|
12
|
+
# nested calls) and return a response to the user.
|
13
|
+
def catch_halt
|
14
|
+
catch(:halt) { yield }
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require_dependency "gold/application_controller"
|
2
|
+
|
3
|
+
module Gold
|
4
|
+
# Similar to ShopifyApp::AuthenticatedController, but inherits from Gold's
|
5
|
+
# ApplicationController.
|
6
|
+
class AuthenticatedController < ApplicationController
|
7
|
+
# Include all of the contents of ShopifyApp::AuthenticatedController
|
8
|
+
include ShopifyApp::Localization
|
9
|
+
include ShopifyApp::LoginProtection
|
10
|
+
include ShopifyApp::EmbeddedApp
|
11
|
+
|
12
|
+
before_action :login_again_if_different_shop
|
13
|
+
around_action :shopify_session
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,185 @@
|
|
1
|
+
module Gold
|
2
|
+
# Handles all merchant billing interactions.
|
3
|
+
class BillingController < AuthenticatedController
|
4
|
+
include Concerns::MerchantFacing
|
5
|
+
include Outcomes
|
6
|
+
|
7
|
+
layout "gold/billing"
|
8
|
+
|
9
|
+
rescue_from Statesman::TransitionFailedError do |e|
|
10
|
+
billing = Billing.find_by!(shop_id: session[:shopify])
|
11
|
+
Gold.logger.error("Shop '#{billing.shop.shopify_domain}' failed to " \
|
12
|
+
"transtion '#{e}'")
|
13
|
+
render "gold/billing/transition_error", layout: "gold/billing",
|
14
|
+
status: :internal_server_error
|
15
|
+
end
|
16
|
+
|
17
|
+
# Show the terms of service.
|
18
|
+
def terms
|
19
|
+
@already_accepted = !billing.can_transition_to?(:accepted_terms)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Handle acceptance or declination of the terms of service.
|
23
|
+
def process_terms
|
24
|
+
accepted = params[:accept].present?
|
25
|
+
outcome = AcceptOrDeclineTermsOp.new(billing, accepted).call
|
26
|
+
case outcome
|
27
|
+
when AcceptedTerms
|
28
|
+
params.permit!
|
29
|
+
Gold.configuration.on_terms&.call(billing, params.to_h)
|
30
|
+
redirect_to select_tier_url
|
31
|
+
else
|
32
|
+
@error = true
|
33
|
+
render :terms
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Show the possible tiers to select from.
|
38
|
+
def tier
|
39
|
+
billing # Expose the @billing variable to the view
|
40
|
+
@tiers = Tier.visible
|
41
|
+
end
|
42
|
+
|
43
|
+
# Process a tier selection.
|
44
|
+
def select_tier
|
45
|
+
@tiers = Tier.visible
|
46
|
+
@tier = Tier.find(params[:tier])
|
47
|
+
outcome = SelectTierOp.new(billing, @tier).call
|
48
|
+
case outcome
|
49
|
+
when SameTier, TierApplied
|
50
|
+
redirect_to main_app.root_url
|
51
|
+
when CannotSelectTier
|
52
|
+
flash.now[:error] = "You may not select this tier currently"
|
53
|
+
render :tier
|
54
|
+
when ChargeNeeded
|
55
|
+
handle_charge_outcome ChargeOp.new(billing, process_charge_url).call
|
56
|
+
else
|
57
|
+
raise "Not sure how to handle #{outcome} on tier selection"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Show the merchant that they have an outstanding charge and let them
|
62
|
+
# approve/decline it.
|
63
|
+
def outstanding_charge
|
64
|
+
outcome = ResolveOutstandingChargeOp.new(billing).call
|
65
|
+
|
66
|
+
case outcome
|
67
|
+
when PendingCharge
|
68
|
+
Gold.logger.info("[#{billing.id}] Charge is pending")
|
69
|
+
@confirmation_url = outcome.confirmation_url
|
70
|
+
when AcceptedCharge
|
71
|
+
Gold.logger.info("[#{billing.id}] Charge is accepted")
|
72
|
+
redirect_to outcome.return_url
|
73
|
+
when MissingCharge
|
74
|
+
Gold.logger.info("[#{billing.id}] Charge is missing")
|
75
|
+
redirect_to missing_charge_url
|
76
|
+
when ExpiredCharge
|
77
|
+
Gold.logger.info("[#{billing.id}] Charge is expired")
|
78
|
+
redirect_to expired_charge_url
|
79
|
+
when ActiveCharge
|
80
|
+
unless billing.current_state == :billing
|
81
|
+
AcceptOrDeclineChargeOp.new(billing, outcome.charge_id).call
|
82
|
+
|
83
|
+
unless ApplyTierOp.new(billing).call.ok?
|
84
|
+
raise "Charge was #{accepted_outcome}, but should have been active"
|
85
|
+
end
|
86
|
+
|
87
|
+
Gold.logger.info("[#{billing.id}] Charge is ready")
|
88
|
+
end
|
89
|
+
|
90
|
+
redirect_to main_app.root_url
|
91
|
+
else
|
92
|
+
raise "Not sure how to handle #{outcome} on outstanding charge"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Process a charge confirmation when a merchant is redirected back from
|
97
|
+
# Shopify.
|
98
|
+
def process_charge
|
99
|
+
outcome = AcceptOrDeclineChargeOp.new(billing, params[:charge_id]).call
|
100
|
+
|
101
|
+
case outcome
|
102
|
+
when ActiveCharge
|
103
|
+
ApplyTierOp.new(billing).call
|
104
|
+
redirect_to main_app.root_url
|
105
|
+
when DeclinedCharge
|
106
|
+
redirect_to declined_charge_url
|
107
|
+
when ExpiredCharge
|
108
|
+
redirect_to expired_charge_url
|
109
|
+
when Uninstalled
|
110
|
+
redirect_to uninstalled_url
|
111
|
+
when CannotProcessCharge
|
112
|
+
redirect_to missing_charge_url
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Show the merchant that the charge has expired and direct them to try
|
117
|
+
# again.
|
118
|
+
def declined_charge
|
119
|
+
ensure_billing_is :sudden_charge_declined, :delayed_charge_declined
|
120
|
+
end
|
121
|
+
|
122
|
+
# Show the merchant that the charge has expired and direct them to try
|
123
|
+
# again.
|
124
|
+
def expired_charge
|
125
|
+
ensure_billing_is :sudden_charge_expired, :delayed_charge_expired
|
126
|
+
end
|
127
|
+
|
128
|
+
# Explain to the merchant that their charge is missing and direct them to
|
129
|
+
# try again.
|
130
|
+
def missing_charge
|
131
|
+
end
|
132
|
+
|
133
|
+
# Allow the merchant to retry the charge when necessary.
|
134
|
+
def retry_charge
|
135
|
+
handle_charge_outcome ChargeOp.new(billing, process_charge_url).call
|
136
|
+
end
|
137
|
+
|
138
|
+
# Show the merchant a suspension page if they have been suspended.
|
139
|
+
def suspended
|
140
|
+
ensure_billing_is :marked_as_suspended, :suspended
|
141
|
+
end
|
142
|
+
|
143
|
+
# Show the merchant a message about them uninstalling the app if they are
|
144
|
+
# still logged in.
|
145
|
+
def uninstalled
|
146
|
+
ensure_billing_is :marked_as_uninstalled,
|
147
|
+
:uninstalled,
|
148
|
+
:frozen,
|
149
|
+
:cleanup,
|
150
|
+
:done
|
151
|
+
end
|
152
|
+
|
153
|
+
private
|
154
|
+
|
155
|
+
# Redirect to the appropriate location based on the return value from
|
156
|
+
# ChargeOp.
|
157
|
+
def handle_charge_outcome(outcome)
|
158
|
+
Gold.logger.info("[#{billing.id}] Handling charge outcome")
|
159
|
+
|
160
|
+
case outcome
|
161
|
+
when ChargeNotNeeded
|
162
|
+
Gold.logger.info("[#{billing.id}] Charge is not needed")
|
163
|
+
redirect_to main_app.root_url
|
164
|
+
when ActiveCharge
|
165
|
+
Gold.logger.info("[#{billing.id}] Charge is active")
|
166
|
+
redirect_to main_app.root_url
|
167
|
+
when AcceptedCharge
|
168
|
+
Gold.logger.info("[#{billing.id}] Charge is accepted")
|
169
|
+
redirect_to outcome.return_url
|
170
|
+
when PendingCharge
|
171
|
+
Gold.logger.info("[#{billing.id}] Charge is pending")
|
172
|
+
redirect_to outcome.confirmation_url
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# Ensure that the current merchant is in one of the provided states or
|
177
|
+
# redirect to the app's root URL.
|
178
|
+
def ensure_billing_is(*states)
|
179
|
+
return if states.include?(billing.current_state)
|
180
|
+
|
181
|
+
redirect_to main_app.root_url
|
182
|
+
throw :halt
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module Gold
|
2
|
+
module Concerns
|
3
|
+
# Helpers that make it easier to work with the current merchant's billing
|
4
|
+
# information in controllers.
|
5
|
+
module MerchantFacing
|
6
|
+
def self.included(base)
|
7
|
+
base.helper_method :billing
|
8
|
+
end
|
9
|
+
|
10
|
+
# Returns the Gold::Billing instance for the currently logged-in merchant.
|
11
|
+
def billing
|
12
|
+
@billing ||= Billing.find_by!(shop_id: session[:shopify])
|
13
|
+
end
|
14
|
+
|
15
|
+
# `before_action` filter that forces a merchant to deal with
|
16
|
+
# billing-related states (outstanding charges, acceptance of terms, etc)
|
17
|
+
# before continuing with their work.
|
18
|
+
def confront_mandatory_billing_action
|
19
|
+
ConfrontMandatoryBillingAction.new(self).call
|
20
|
+
end
|
21
|
+
|
22
|
+
# Provides access to routes, passing in a particular controller as
|
23
|
+
# context. We go through the effort of putting this into a separate class
|
24
|
+
# because we do not want to directly include the url helpers into whatever
|
25
|
+
# controller we are included into because that might override routes. This
|
26
|
+
# solution isolates the Gold routes from whatever routes the app that Gold
|
27
|
+
# is embedded in may have defined.
|
28
|
+
class HasAccessToRoutes
|
29
|
+
include Engine.routes.url_helpers
|
30
|
+
delegate :default_url_options, :url_options, to: :@context
|
31
|
+
|
32
|
+
def initialize(context)
|
33
|
+
@context = context
|
34
|
+
@engine = context.gold_engine
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# See documentation on `confront_mandatory_billing_action`.
|
39
|
+
class ConfrontMandatoryBillingAction < HasAccessToRoutes
|
40
|
+
def call
|
41
|
+
begin
|
42
|
+
state = @context.billing.current_state
|
43
|
+
rescue ActiveRecord::RecordNotFound
|
44
|
+
state = nil
|
45
|
+
end
|
46
|
+
|
47
|
+
Gold.logger.info("Confronting billing, state is '#{state}'")
|
48
|
+
|
49
|
+
case state
|
50
|
+
when nil, :new
|
51
|
+
Gold.logger.info("Redirecting to terms page...")
|
52
|
+
return @context.redirect_to(@engine.terms_url)
|
53
|
+
when :select_tier, :reinstalled, :accepted_terms
|
54
|
+
Gold.logger.info("Redirecting to select tier...")
|
55
|
+
return @context.redirect_to(@engine.select_tier_url)
|
56
|
+
when :charge_missing
|
57
|
+
Gold.logger.info("Redirecting to missing charge...")
|
58
|
+
return @context.redirect_to(@engine.missing_charge_url)
|
59
|
+
when :sudden_charge, :delayed_charge, :optional_charge
|
60
|
+
Gold.logger.info("Redirecting to outstanding charge...")
|
61
|
+
return @context.redirect_to(@engine.outstanding_charge_url)
|
62
|
+
when :sudden_charge_declined, :delayed_charge_declined
|
63
|
+
Gold.logger.info("Redirecting to declined charge...")
|
64
|
+
return @context.redirect_to(@engine.declined_charge_url)
|
65
|
+
when :sudden_charge_expired, :delayed_charge_expired
|
66
|
+
Gold.logger.info("Redirecting to expired charge...")
|
67
|
+
return @context.redirect_to(@engine.expired_charge_url)
|
68
|
+
when :marked_as_delinquent, :delinquent
|
69
|
+
Gold.logger.info("Redirecting to missing charge...")
|
70
|
+
return @context.redirect_to(@engine.missing_charge_url)
|
71
|
+
when :marked_as_suspended, :suspended
|
72
|
+
Gold.logger.info("Redirecting to suspended page...")
|
73
|
+
return @context.redirect_to(@engine.suspended_path)
|
74
|
+
when :marked_as_uninstalled, :uninstalled, :frozen, :done
|
75
|
+
Gold.logger.info("Redirecting to uninstalled page...")
|
76
|
+
return @context.redirect_to(@engine.uninstalled_path)
|
77
|
+
when :cleanup
|
78
|
+
Gold.logger.info("Shop is cleanup, redirecting to unavailable...")
|
79
|
+
return @context.redirect_to(@engine.unavailable_path)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Gold
|
2
|
+
# Run when the app is installed, reinstalled, or a user logs in. This is
|
3
|
+
# essentially how Gold is bootstrapped, so there is an assumption made
|
4
|
+
# here (the shop class has a `#with_shopify_session` method). This is true
|
5
|
+
# with the default Shop class that Shopify sets up, but if that is different
|
6
|
+
# for your environment, you will need to create your own custom version of
|
7
|
+
# this job.
|
8
|
+
class AfterAuthenticateJob < ApplicationJob
|
9
|
+
include Outcomes
|
10
|
+
|
11
|
+
def perform(shop_domain:)
|
12
|
+
# Look up the shop associated with the Shopify session.
|
13
|
+
shop = Gold.shop_class.find_by!(Gold.shop_domain_attribute => shop_domain)
|
14
|
+
|
15
|
+
# Create a billing instance if this store is newly installed or already
|
16
|
+
# cleaned up.
|
17
|
+
billing = Billing.find_or_create_by!(shop: shop)
|
18
|
+
|
19
|
+
shop.with_shopify_session do
|
20
|
+
outcome = InstallOp.new(billing).call
|
21
|
+
|
22
|
+
case outcome
|
23
|
+
when ChargeNeeded, MissingCharge
|
24
|
+
process_charge_url = Engine.routes.url_helpers.process_charge_url
|
25
|
+
logger.info("#{shop_domain} has reinstalled and needs to approve a " \
|
26
|
+
"charge for their selected tier")
|
27
|
+
ChargeOp.new(billing, process_charge_url).call
|
28
|
+
when Success
|
29
|
+
logger.info("#{shop_domain} successfully installed the app")
|
30
|
+
end
|
31
|
+
|
32
|
+
outcome
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
module Gold
|
2
|
+
# Uninstalls the app for the merchant.
|
3
|
+
class AppUninstalledJob < ApplicationJob
|
4
|
+
def perform(shop_domain:, webhook: nil) # rubocop:disable Lint/UnusedMethodArgument
|
5
|
+
shop = Gold.shop_class.find_by!(Gold.shop_domain_attribute => shop_domain)
|
6
|
+
billing = Gold::Billing.find_by!(shop: shop)
|
7
|
+
UninstallOp.new(billing).call
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Gold
|
2
|
+
# Base for all Gold-related background jobs to build upon.
|
3
|
+
class ApplicationJob < ActiveJob::Base
|
4
|
+
include Engine.routes.url_helpers
|
5
|
+
|
6
|
+
queue_as :gold
|
7
|
+
|
8
|
+
# Automatically retry jobs that encountered a deadlock
|
9
|
+
# retry_on ActiveRecord::Deadlocked
|
10
|
+
|
11
|
+
# Most jobs are safe to ignore if the underlying records are no longer available
|
12
|
+
# discard_on ActiveJob::DeserializationError
|
13
|
+
|
14
|
+
protected
|
15
|
+
|
16
|
+
def default_url_options
|
17
|
+
Rails.application.config.default_url_options
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Gold
|
2
|
+
# Sends billing-related emails to merchants.
|
3
|
+
class BillingMailer < ApplicationMailer
|
4
|
+
layout "gold/mailer"
|
5
|
+
|
6
|
+
# Inform a merchant about their account becoming suspended.
|
7
|
+
def suspension(billing)
|
8
|
+
billing.shop.with_shopify_session do
|
9
|
+
@shop = ShopifyAPI::Shop.current
|
10
|
+
mail(to: @shop.email,
|
11
|
+
subject: "We have suspended your access to #{Gold.configuration.app_name}")
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Inform a merchant about their non-payment.
|
16
|
+
def delinquent(billing)
|
17
|
+
billing.shop.with_shopify_session do
|
18
|
+
@shop = ShopifyAPI::Shop.current
|
19
|
+
mail(to: @shop.email,
|
20
|
+
subject: "We need you to approve a charge for " \
|
21
|
+
"#{Gold.configuration.app_name}")
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def affiliate_to_paid(billing, confirmation_url)
|
26
|
+
billing.shop.with_shopify_session do
|
27
|
+
@shop = ShopifyAPI::Shop.current
|
28
|
+
@confirmation_url = confirmation_url
|
29
|
+
app_name = Gold.configuration.app_name
|
30
|
+
|
31
|
+
mail(to: @shop.email,
|
32
|
+
subject: "[Action Required] Charge needed for #{app_name}")
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|