shopify-gold 2.0.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- 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,22 @@
|
|
1
|
+
<h1>Terms of Service</h1>
|
2
|
+
|
3
|
+
<% if @error %>
|
4
|
+
<p class="error">
|
5
|
+
You must accept the terms of service before continuing.
|
6
|
+
</p>
|
7
|
+
<% end %>
|
8
|
+
|
9
|
+
<p>
|
10
|
+
This is where a set of terms and conditions should go.
|
11
|
+
</p>
|
12
|
+
|
13
|
+
<% if @already_accepted %>
|
14
|
+
<p>
|
15
|
+
You have already accepted these terms.
|
16
|
+
</p>
|
17
|
+
<% else %>
|
18
|
+
<%= form_tag(terms_path, method: :put) do %>
|
19
|
+
<%= submit_tag "Decline", name: "decline" %>
|
20
|
+
<%= submit_tag "Accept", name: "accept" %>
|
21
|
+
<% end %>
|
22
|
+
<% end %>
|
@@ -0,0 +1,29 @@
|
|
1
|
+
<h1>Select a Tier</h1>
|
2
|
+
|
3
|
+
<p>
|
4
|
+
Here are the tiers that are available to you. You can always come back here
|
5
|
+
later and choose a different tier.
|
6
|
+
</p>
|
7
|
+
|
8
|
+
<%= form_tag(select_tier_path, method: :put) do %>
|
9
|
+
<% @tiers.each do |tier| %>
|
10
|
+
<div>
|
11
|
+
<% @current_tier = billing.tier == tier %>
|
12
|
+
<% @unavailable = !billing.qualifies_for_tier?(tier) %>
|
13
|
+
<label>
|
14
|
+
<%= radio_button_tag :tier, tier.id, @current_tier, disabled: @unavailable %>
|
15
|
+
<%= tier.name %>
|
16
|
+
<% if @current_tier && @unavailable %>
|
17
|
+
(This your current tier and you have outgrown it with your usage)
|
18
|
+
<% elsif @current_tier %>
|
19
|
+
(This is your current tier)
|
20
|
+
<% elsif @unavailable %>
|
21
|
+
(This tier is not available based on your current usage)
|
22
|
+
<% end %>
|
23
|
+
</label>
|
24
|
+
</div>
|
25
|
+
<% end %>
|
26
|
+
|
27
|
+
<%= link_to "Cancel", main_app.root_path %>
|
28
|
+
<%= submit_tag "Select" %>
|
29
|
+
<% end %>
|
@@ -0,0 +1,9 @@
|
|
1
|
+
<p class="Polaris-DisplayText Polaris-DisplayText--sizeLarge">
|
2
|
+
Request failed
|
3
|
+
</p>
|
4
|
+
|
5
|
+
<p>
|
6
|
+
We're sorry, but we couldn't respond to your request. Please try again
|
7
|
+
in a few minutes. If the problem persists, please reach out to our
|
8
|
+
support and we'll get right on it.
|
9
|
+
</p>
|
@@ -0,0 +1,8 @@
|
|
1
|
+
<p class="Polaris-DisplayText Polaris-DisplayText--sizeLarge">
|
2
|
+
Install unavailable
|
3
|
+
</p>
|
4
|
+
|
5
|
+
<p>Drat! We're sorry, but we couldn't install the app at this time. Please try again in a few minutes or contact our support.</p>
|
6
|
+
<div style="height: 25px;"></div>
|
7
|
+
<a class="Polaris-Button" href="<%= shopify_app.login_path %>">Try again</a>
|
8
|
+
|
@@ -0,0 +1,13 @@
|
|
1
|
+
<p class="Polaris-DisplayText Polaris-DisplayText--sizeLarge">
|
2
|
+
<%= Gold.configuration.app_name %> is uninstalled
|
3
|
+
</p>
|
4
|
+
|
5
|
+
|
6
|
+
<p>
|
7
|
+
It looks like you have uninstalled this app recently or your store was previously frozen.
|
8
|
+
</p>
|
9
|
+
<div style="height: 25px;"></div>
|
10
|
+
<p>
|
11
|
+
<a class="Polaris-Button Polaris-Button--primary-button"
|
12
|
+
href="<%= shopify_app.login_path %>">Reinstall</a>
|
13
|
+
</p>
|
@@ -0,0 +1,23 @@
|
|
1
|
+
<h1>Please approve app charges</h1>
|
2
|
+
<p>Hey <%= @shop.shop_owner %>,</p>
|
3
|
+
|
4
|
+
<p>
|
5
|
+
We noticed that you've updated your store from a development shop
|
6
|
+
to a paid plan. Feels good doesn't it?
|
7
|
+
</p>
|
8
|
+
|
9
|
+
<p>
|
10
|
+
Since we don't charge for partner stores, we haven't been charging
|
11
|
+
you to use <%= Gold.configuration.app_name %>. Now that your store is live, you'll
|
12
|
+
have to approve a charge to continue using the app.
|
13
|
+
<strong>
|
14
|
+
You must approve this charge within <%= Gold.configuration.days_until_delinquent %> days to avoid interruption of service.
|
15
|
+
</strong>
|
16
|
+
</p>
|
17
|
+
|
18
|
+
<p>
|
19
|
+
<a class="button" href="<%= @confirmation_url %>">Approve charge</a>
|
20
|
+
</p>
|
21
|
+
<div>
|
22
|
+
or <a href="<%= Gold.configuration.plan_comparison_url %>">compare plans</a>
|
23
|
+
</div>
|
@@ -0,0 +1,21 @@
|
|
1
|
+
<h1>Your account has been frozen</h1>
|
2
|
+
<p>Hey <%= @shop.shop_owner %>,</p>
|
3
|
+
<p>
|
4
|
+
We have suspended access to your <%= Gold.configuration.app_name %> usage because we
|
5
|
+
have not received an authorization of payment towards your new billing tier.
|
6
|
+
</p>
|
7
|
+
<p>
|
8
|
+
Please respond to this email promptly by logging into the app and approving
|
9
|
+
the displayed charge. If we do not hear from you for more than 30 days, we may
|
10
|
+
choose to delete your account.
|
11
|
+
</p>
|
12
|
+
<p>
|
13
|
+
<a href="<%= shopify_app.login_url %>" class="button">Log in</a>
|
14
|
+
</p>
|
15
|
+
|
16
|
+
<p>
|
17
|
+
Regards,
|
18
|
+
</p>
|
19
|
+
<p>
|
20
|
+
Helium Support
|
21
|
+
</p>
|
@@ -0,0 +1,19 @@
|
|
1
|
+
<h1>Your account has been suspended</h1>
|
2
|
+
<p>Hey <%= @shop.shop_owner %>,</p>
|
3
|
+
<p>
|
4
|
+
We've suspended access to your <%= Gold.configuration.app_name %> usage because we
|
5
|
+
believe you are in violation of our terms of service. Until we have reconciled
|
6
|
+
this problem, you will not be able to access the app, nor will your customers
|
7
|
+
be able to interact with it.
|
8
|
+
</p>
|
9
|
+
<p>
|
10
|
+
Please respond to this email promptly by asking how to resolve this suspension
|
11
|
+
and we can advise you on how to proceed. If we do not hear from you for more
|
12
|
+
than 30 days, we may choose to delete your account.
|
13
|
+
</p>
|
14
|
+
<p>
|
15
|
+
Regards,
|
16
|
+
</p>
|
17
|
+
<p>
|
18
|
+
Helium Support
|
19
|
+
</p>
|
@@ -0,0 +1,22 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
5
|
+
<title><%= Gold.configuration.app_name %></title>
|
6
|
+
|
7
|
+
<%= stylesheet_link_tag "shopify/polaris" %>
|
8
|
+
<%= stylesheet_link_tag "gold/billing" %>
|
9
|
+
|
10
|
+
<%= render "gold/billing/inner_head" %>
|
11
|
+
</head>
|
12
|
+
|
13
|
+
<body>
|
14
|
+
<main>
|
15
|
+
<div class="main-content">
|
16
|
+
<%= yield %>
|
17
|
+
</div>
|
18
|
+
</main>
|
19
|
+
<aside></aside>
|
20
|
+
<%= javascript_include_tag "gold/billing" %>
|
21
|
+
</body>
|
22
|
+
</html>
|
@@ -0,0 +1 @@
|
|
1
|
+
<%= yield %>
|
data/config/routes.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
Gold::Engine.routes.draw do
|
2
|
+
get "/terms", to: "billing#terms", as: "terms"
|
3
|
+
put "/terms", to: "billing#process_terms"
|
4
|
+
|
5
|
+
get "/tier", to: "billing#tier", as: "select_tier"
|
6
|
+
put "/tier", to: "billing#select_tier"
|
7
|
+
|
8
|
+
get "/charge", to: "billing#outstanding_charge", as: "outstanding_charge"
|
9
|
+
get "/charge/process", to: "billing#process_charge", as: "process_charge"
|
10
|
+
get "/charge/expired", to: "billing#expired_charge", as: "expired_charge"
|
11
|
+
get "/charge/declined", to: "billing#declined_charge", as: "declined_charge"
|
12
|
+
get "/charge/missing", to: "billing#missing_charge", as: "missing_charge"
|
13
|
+
post "/charge/retry", to: "billing#retry_charge", as: "retry_charge"
|
14
|
+
|
15
|
+
get "/suspended", to: "billing#suspended", as: "suspended"
|
16
|
+
get "/unavailable", to: "billing#unavailable", as: "unavailable"
|
17
|
+
|
18
|
+
get "/uninstalled", to: "billing#uninstalled", as: "uninstalled"
|
19
|
+
end
|
20
|
+
|
21
|
+
Gold::AdminEngine.routes.draw do
|
22
|
+
scope :billing do
|
23
|
+
post '/credit', to: 'admin/billing#issue_credit', as: 'issue_credit'
|
24
|
+
put '/suspend', to: 'admin/billing#suspend', as: 'suspend'
|
25
|
+
put '/unsuspend', to: 'admin/billing#unsuspend', as: 'unsuspend'
|
26
|
+
put '/reset_trial_days', to: 'admin/billing#reset_trial_days', as: 'reset_trial_days'
|
27
|
+
put '/discount', to: 'admin/billing#apply_discount', as: 'apply_discount'
|
28
|
+
put '/transition', to: 'admin/billing#transition', as: 'transition'
|
29
|
+
put '/change_tier', to: 'admin/billing#change_tier', as: 'change_tier'
|
30
|
+
put '/override_shopify_plan', to: 'admin/billing#override_shopify_plan', as: 'override_shopify_plan'
|
31
|
+
delete '/charge/:id', to: 'admin/billing#cancel_charge', as: 'cancel_charge'
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
class CreateGoldBilling < ActiveRecord::Migration[5.2]
|
2
|
+
def change
|
3
|
+
create_table :gold_billings do |t|
|
4
|
+
t.integer :shop_id, null: false, index: { unique: true }
|
5
|
+
t.timestamp :trial_starts_at
|
6
|
+
t.string :tier_id
|
7
|
+
t.string :shopify_plan_override
|
8
|
+
t.integer :discount_percentage, null: false, default: 0
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
class CreateGoldTransitions < ActiveRecord::Migration[5.2]
|
2
|
+
def change
|
3
|
+
create_table :gold_transitions do |t|
|
4
|
+
t.string :to_state, null: false
|
5
|
+
t.json :metadata, default: {}
|
6
|
+
t.integer :sort_key, null: false
|
7
|
+
t.integer :billing_id, null: false
|
8
|
+
t.boolean :most_recent, null: false
|
9
|
+
|
10
|
+
# If you decide not to include an updated timestamp column in your transition
|
11
|
+
# table, you'll need to configure the `updated_timestamp_column` setting in your
|
12
|
+
# migration class.
|
13
|
+
t.timestamps null: false
|
14
|
+
end
|
15
|
+
|
16
|
+
# Foreign keys are optional, but highly recommended
|
17
|
+
add_foreign_key :gold_transitions, :gold_billings, column: :billing_id
|
18
|
+
|
19
|
+
add_index(:gold_transitions,
|
20
|
+
[:billing_id, :sort_key],
|
21
|
+
unique: true,
|
22
|
+
name: "index_gold_transitions_parent_sort")
|
23
|
+
add_index(:gold_transitions,
|
24
|
+
[:billing_id, :most_recent],
|
25
|
+
unique: true,
|
26
|
+
where: 'most_recent',
|
27
|
+
name: "index_gold_transitions_parent_most_recent")
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
class AddForeignKeyToBilling < ActiveRecord::Migration[5.2]
|
2
|
+
def change
|
3
|
+
# TODO (nick/andrew) make this from a generator, which takes the
|
4
|
+
# Gold.shop_class and uses the correct table as the primary key
|
5
|
+
add_foreign_key :gold_billings, :shops, column: :shop_id, primary_key: :id
|
6
|
+
# |^^^^^| change this
|
7
|
+
end
|
8
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require "bigdecimal"
|
2
|
+
|
3
|
+
module Gold
|
4
|
+
# Migrates previous systems to Gold for a specific shop
|
5
|
+
class BillingMigrator
|
6
|
+
attr_reader :shop,
|
7
|
+
:tier_id,
|
8
|
+
:tier,
|
9
|
+
:billing
|
10
|
+
|
11
|
+
def initialize(shop, tier_id, trial_starts_at = nil)
|
12
|
+
@shop = shop
|
13
|
+
@tier_id = tier_id.to_sym
|
14
|
+
@trial_starts_at = trial_starts_at
|
15
|
+
end
|
16
|
+
|
17
|
+
# Finds an appropriate tier based on tier_id and current charge
|
18
|
+
def lookup_tier!
|
19
|
+
charge = ShopifyAPI::RecurringApplicationCharge.current
|
20
|
+
|
21
|
+
# First attempt to find a matching tier with name and price
|
22
|
+
tier = Tier.all.find do |t|
|
23
|
+
(t.id == @tier_id || t.parent&.id == @tier_id) &&
|
24
|
+
tier_matches_charge_price(t, charge)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Fallback on just tier id
|
28
|
+
tier ||= Tier.find(@tier_id)
|
29
|
+
|
30
|
+
# Raise an exception if no tier is found
|
31
|
+
raise Outcomes::TierNotFound unless tier
|
32
|
+
|
33
|
+
@tier = tier
|
34
|
+
end
|
35
|
+
|
36
|
+
def migrate!
|
37
|
+
if Billing.find_by(shop_id: shop.id)
|
38
|
+
Gold.logger.warn("Attempted to migrate shop '#{@shop.shopify_domain}'" \
|
39
|
+
", but Billing record already exists")
|
40
|
+
return false
|
41
|
+
end
|
42
|
+
|
43
|
+
@billing = Billing.create!(
|
44
|
+
shop: @shop,
|
45
|
+
trial_starts_at: @trial_starts_at
|
46
|
+
)
|
47
|
+
|
48
|
+
lookup_tier!
|
49
|
+
accept_terms
|
50
|
+
select_tier
|
51
|
+
|
52
|
+
Gold.logger.info("Migrated shop '#{@shop.shopify_domain}', state is " \
|
53
|
+
"'#{@billing.current_state}', tier is '#{@tier.id}'")
|
54
|
+
|
55
|
+
true
|
56
|
+
rescue ActiveResource::UnauthorizedAccess,
|
57
|
+
ActiveResource::ForbiddenAccess,
|
58
|
+
ActiveResource::ResourceNotFound
|
59
|
+
Gold.logger.info("Shop '#{@shop.shopify_domain}' is uninstalled, skipping...")
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def accept_terms
|
65
|
+
AcceptOrDeclineTermsOp.new(@billing, true).call
|
66
|
+
end
|
67
|
+
|
68
|
+
def select_tier
|
69
|
+
outcome = SelectTierOp.new(@billing, @tier, false).call
|
70
|
+
|
71
|
+
charge_op if outcome.is_a?(Outcomes::ChargeNeeded)
|
72
|
+
end
|
73
|
+
|
74
|
+
def charge_op
|
75
|
+
return_url = Engine.routes.url_helpers.process_charge_url
|
76
|
+
ChargeOp.new(@billing, return_url).call
|
77
|
+
|
78
|
+
charge_transition = @billing.state_machine.last_transition_to(:sudden_charge)
|
79
|
+
|
80
|
+
unless charge_transition
|
81
|
+
raise "Expected billing '#{@billing.id}' to have sudden_charge transition"
|
82
|
+
end
|
83
|
+
|
84
|
+
accept_charge(charge_transition.metadata["charge_id"])
|
85
|
+
end
|
86
|
+
|
87
|
+
def accept_charge(charge_id)
|
88
|
+
outcome = AcceptOrDeclineChargeOp.new(@billing, charge_id).call
|
89
|
+
|
90
|
+
if outcome.ok?
|
91
|
+
apply_tier
|
92
|
+
else
|
93
|
+
Rails.logger.info("Charge for '#{@shop.shopify_domain}' was '#{outcome}'")
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def apply_tier
|
98
|
+
ApplyTierOp.new(@billing).call.ok?
|
99
|
+
end
|
100
|
+
|
101
|
+
def tier_matches_charge_price(tier, charge)
|
102
|
+
return false unless charge
|
103
|
+
|
104
|
+
tier.monthly_price == charge.price.to_d
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
module Gold
|
2
|
+
# Captures coverage on Statesman state machines. Include this module into the
|
3
|
+
# `Statesman::Machine` subclass that you want to record usage for. Results may
|
4
|
+
# be accessed with `#coverage` on that class (not the instances).
|
5
|
+
module Coverage
|
6
|
+
def self.included(klass)
|
7
|
+
klass.extend(ClassMethods)
|
8
|
+
end
|
9
|
+
|
10
|
+
# Class methods to add to the state machine when Gold::Coverage is included.
|
11
|
+
module ClassMethods
|
12
|
+
def coverage
|
13
|
+
@coverage ||= Results.new(self)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# Whether or not we are collecting coverage information currently.
|
18
|
+
def self.covered?
|
19
|
+
@covered = @covered.nil? || @covered
|
20
|
+
end
|
21
|
+
|
22
|
+
# Set whether we are collecting coverage information currently.
|
23
|
+
def self.covered=(covered)
|
24
|
+
@covered = covered
|
25
|
+
end
|
26
|
+
|
27
|
+
# Execute a block and don't include it in the coverage results.
|
28
|
+
def self.exclude_from_coverage
|
29
|
+
original = covered?
|
30
|
+
self.covered = false
|
31
|
+
result = yield
|
32
|
+
self.covered = original
|
33
|
+
result
|
34
|
+
end
|
35
|
+
|
36
|
+
# Describes a transition between two states.
|
37
|
+
Transition = Struct.new(:from, :to)
|
38
|
+
|
39
|
+
# Holds the collected coverage data.
|
40
|
+
class Results
|
41
|
+
def initialize(machine)
|
42
|
+
@machine = machine
|
43
|
+
@covered = Set.new
|
44
|
+
end
|
45
|
+
|
46
|
+
# Records when a transition is made from one state to another.
|
47
|
+
def cover(from, to)
|
48
|
+
@covered.add(Transition.new(from, to))
|
49
|
+
end
|
50
|
+
|
51
|
+
# Iterates over transitions that have been made thus far. This is in the
|
52
|
+
# same form as `Statesman::Machine#successors`, so the two can be compared
|
53
|
+
# to see where coverage is lacking.
|
54
|
+
def to_h
|
55
|
+
@covered.each_with_object(Hash.new { [] }) do |transition, hash|
|
56
|
+
hash[transition.from] = hash[transition.from] << transition.to
|
57
|
+
hash
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Generates an SVG diagram of the coverage that has been captured so far.
|
62
|
+
# Covered transitions are colored green and uncovered transitions are
|
63
|
+
# colored red.
|
64
|
+
def to_svg(path)
|
65
|
+
coverage = to_h
|
66
|
+
callback = lambda do |edge|
|
67
|
+
from = edge.tail_node
|
68
|
+
to = edge.head_node
|
69
|
+
edge[:color] =
|
70
|
+
coverage.key?(from) && coverage[from].include?(to) ? "green" : "red"
|
71
|
+
end
|
72
|
+
Gold::Diagram.new(@machine, edge_callback: callback).render(path)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Override `#transition_to!` to observe the current state and the new state.
|
77
|
+
# Statesman's callbacks do not currently allow for this, hence the need to
|
78
|
+
# intercept here. `#transition_to` calls `#transition_to!`, so it is
|
79
|
+
# sufficient to tap into `Statesman::Machine` at this one point.
|
80
|
+
def transition_to!(new_state, metadata = {})
|
81
|
+
initial_state = current_state
|
82
|
+
super(new_state, metadata)
|
83
|
+
if Gold::Coverage.covered?
|
84
|
+
self.class.coverage.cover(initial_state.to_s, new_state.to_s)
|
85
|
+
end
|
86
|
+
true
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
data/lib/gold/diagram.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
require "graphviz"
|
2
|
+
|
3
|
+
module Gold
|
4
|
+
# Renders graphs of `Statesman::Machine`s.
|
5
|
+
class Diagram
|
6
|
+
# Prepares to render a diagram from a `Statesman::Machine` class.
|
7
|
+
def initialize(machine, node_callback: nil, edge_callback: nil)
|
8
|
+
@machine = machine
|
9
|
+
@node_callback = node_callback
|
10
|
+
@edge_callback = edge_callback
|
11
|
+
end
|
12
|
+
|
13
|
+
# Renders a diagram and saves it to a SVG stored at `output`.
|
14
|
+
def render(output)
|
15
|
+
g = GraphViz.new(@machine.to_s, type: :digraph)
|
16
|
+
|
17
|
+
# Mark the start of the state machine with a "start" label and arrow to the
|
18
|
+
# initial state.
|
19
|
+
g.add_node("start", shape: :plaintext)
|
20
|
+
g.add_edge("start", @machine.initial_state)
|
21
|
+
|
22
|
+
# Add all of the states as nodes.
|
23
|
+
@machine.states.each do |state|
|
24
|
+
node = g.add_node(state)
|
25
|
+
@node_callback&.call(node)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Add edges between connected states.
|
29
|
+
@machine.successors.each do |state, successors|
|
30
|
+
successors.each do |successor|
|
31
|
+
edge = g.add_edge(state, successor)
|
32
|
+
@edge_callback&.call(edge)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Save the output to a file.
|
37
|
+
g.output(svg: output)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/gold/engine.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
module Gold
|
2
|
+
# Brings the Gold to your app. Mount this in your config/routes.rb.
|
3
|
+
class Engine < ::Rails::Engine
|
4
|
+
isolate_namespace Gold
|
5
|
+
config.autoload_paths << File.expand_path("../../lib", __dir__)
|
6
|
+
engine_name "gold_engine"
|
7
|
+
|
8
|
+
initializer "gold.assets.precompile" do |app|
|
9
|
+
app.config.assets.precompile += %w[
|
10
|
+
gold/billing.css
|
11
|
+
gold/billing.js
|
12
|
+
shopify/polaris.css
|
13
|
+
]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
module Gold
|
2
|
+
module Outcomes
|
3
|
+
# This is the base class for any outcome. Sub-classes are intended to
|
4
|
+
# inherit from this and define whether that outcome is considered ok or not.
|
5
|
+
#
|
6
|
+
# If any subclass defines an `initialize` method, it should call `super` to
|
7
|
+
# ensure the `@ok` variable is set.
|
8
|
+
class Outcome < StandardError
|
9
|
+
def initialize(is_ok, message = nil)
|
10
|
+
@ok = is_ok
|
11
|
+
super(message)
|
12
|
+
end
|
13
|
+
|
14
|
+
def ok?
|
15
|
+
@ok
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# General failure class
|
20
|
+
class Failure < Outcome
|
21
|
+
attr_reader :reason
|
22
|
+
|
23
|
+
def initialize(reason = nil, message = nil)
|
24
|
+
@reason = reason
|
25
|
+
super(false, message || reason)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# A general success class
|
30
|
+
class Success < Outcome
|
31
|
+
def initialize(message = nil)
|
32
|
+
super(true, message)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Common outcomes
|
37
|
+
Uninstalled = Class.new(Failure)
|
38
|
+
|
39
|
+
# Outcomes that relate to tiers
|
40
|
+
TierApplied = Class.new(Success)
|
41
|
+
TierNotFound = Class.new(Failure)
|
42
|
+
|
43
|
+
# Outcomes that relate to charges
|
44
|
+
ChargeNeeded = Class.new(Success)
|
45
|
+
ChargeNotNeeded = Class.new(Success)
|
46
|
+
FrozenCharge = Class.new(Success)
|
47
|
+
ExpiredCharge = Class.new(Failure)
|
48
|
+
MissingCharge = Class.new(Failure)
|
49
|
+
|
50
|
+
ActiveCharge = Class.new(Success) do
|
51
|
+
attr_reader :charge_id
|
52
|
+
|
53
|
+
def initialize(charge_id)
|
54
|
+
@charge_id = charge_id
|
55
|
+
super()
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
AcceptedCharge = Class.new(Success) do
|
60
|
+
attr_reader :return_url
|
61
|
+
|
62
|
+
def initialize(return_url)
|
63
|
+
@return_url = return_url
|
64
|
+
super()
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
PendingCharge = Class.new(Success) do
|
69
|
+
attr_reader :confirmation_url
|
70
|
+
|
71
|
+
def initialize(confirmation_url)
|
72
|
+
@confirmation_url = confirmation_url
|
73
|
+
super()
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
data/lib/gold/retries.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
module Gold
|
2
|
+
# For handling network request retries
|
3
|
+
module Retries
|
4
|
+
def request_with_retries(max_attempts = 3)
|
5
|
+
attempts = 0
|
6
|
+
|
7
|
+
begin
|
8
|
+
attempts += 1
|
9
|
+
yield
|
10
|
+
rescue ActiveResource::ConnectionError => e
|
11
|
+
# Maybe this is a transient error? Try it again up to the max amount
|
12
|
+
Gold.logger.error("Failed to make request: #{e}")
|
13
|
+
|
14
|
+
case e
|
15
|
+
when ActiveResource::ServerError
|
16
|
+
Gold.logger.info e.response.code.inspect
|
17
|
+
raise e unless %w[500 502 503 504].include?(e.response.code)
|
18
|
+
when ActiveResource::SSLError
|
19
|
+
Gold.logger.warn "SSL Error"
|
20
|
+
when ActiveResource::TimeoutError
|
21
|
+
Gold.logger.warn "Timeout Error"
|
22
|
+
else
|
23
|
+
raise e
|
24
|
+
end
|
25
|
+
|
26
|
+
retry unless attempts > max_attempts
|
27
|
+
raise e
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/gold/version.rb
ADDED