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