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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +144 -0
  3. data/Rakefile +32 -0
  4. data/app/assets/config/gold_manifest.js +0 -0
  5. data/app/assets/images/gold/app-logo.svg +17 -0
  6. data/app/assets/images/gold/aside-bg.svg +54 -0
  7. data/app/assets/javascripts/gold/billing.js +30 -0
  8. data/app/assets/stylesheets/gold/billing.sass +64 -0
  9. data/app/assets/stylesheets/shopify/polaris.css +1 -0
  10. data/app/controllers/gold/admin/billing_controller.rb +190 -0
  11. data/app/controllers/gold/application_controller.rb +17 -0
  12. data/app/controllers/gold/authenticated_controller.rb +15 -0
  13. data/app/controllers/gold/billing_controller.rb +185 -0
  14. data/app/controllers/gold/concerns/merchant_facing.rb +85 -0
  15. data/app/jobs/app_uninstalled_job.rb +2 -0
  16. data/app/jobs/gold/after_authenticate_job.rb +36 -0
  17. data/app/jobs/gold/app_uninstalled_job.rb +10 -0
  18. data/app/jobs/gold/application_job.rb +20 -0
  19. data/app/mailers/gold/application_mailer.rb +7 -0
  20. data/app/mailers/gold/billing_mailer.rb +36 -0
  21. data/app/models/gold/billing.rb +157 -0
  22. data/app/models/gold/concerns/gilded.rb +22 -0
  23. data/app/models/gold/machine.rb +301 -0
  24. data/app/models/gold/shopify_plan.rb +70 -0
  25. data/app/models/gold/tier.rb +97 -0
  26. data/app/models/gold/transition.rb +26 -0
  27. data/app/operations/gold/accept_or_decline_charge_op.rb +119 -0
  28. data/app/operations/gold/accept_or_decline_terms_op.rb +26 -0
  29. data/app/operations/gold/apply_discount_op.rb +32 -0
  30. data/app/operations/gold/apply_tier_op.rb +51 -0
  31. data/app/operations/gold/charge_op.rb +99 -0
  32. data/app/operations/gold/check_charge_op.rb +50 -0
  33. data/app/operations/gold/cleanup_op.rb +20 -0
  34. data/app/operations/gold/convert_affiliate_to_paid_op.rb +32 -0
  35. data/app/operations/gold/freeze_op.rb +15 -0
  36. data/app/operations/gold/install_op.rb +30 -0
  37. data/app/operations/gold/issue_credit_op.rb +55 -0
  38. data/app/operations/gold/mark_as_delinquent_op.rb +21 -0
  39. data/app/operations/gold/resolve_outstanding_charge_op.rb +90 -0
  40. data/app/operations/gold/select_tier_op.rb +58 -0
  41. data/app/operations/gold/suspend_op.rb +21 -0
  42. data/app/operations/gold/uninstall_op.rb +20 -0
  43. data/app/operations/gold/unsuspend_op.rb +20 -0
  44. data/app/views/gold/admin/billing/_active_charge.erb +29 -0
  45. data/app/views/gold/admin/billing/_credit.erb +46 -0
  46. data/app/views/gold/admin/billing/_discount.erb +23 -0
  47. data/app/views/gold/admin/billing/_history.erb +65 -0
  48. data/app/views/gold/admin/billing/_overview.erb +14 -0
  49. data/app/views/gold/admin/billing/_shopify_plan.erb +21 -0
  50. data/app/views/gold/admin/billing/_state.erb +26 -0
  51. data/app/views/gold/admin/billing/_status.erb +25 -0
  52. data/app/views/gold/admin/billing/_tier.erb +155 -0
  53. data/app/views/gold/admin/billing/_trial_days.erb +42 -0
  54. data/app/views/gold/billing/_inner_head.html.erb +1 -0
  55. data/app/views/gold/billing/declined_charge.html.erb +20 -0
  56. data/app/views/gold/billing/expired_charge.html.erb +14 -0
  57. data/app/views/gold/billing/missing_charge.html.erb +22 -0
  58. data/app/views/gold/billing/outstanding_charge.html.erb +12 -0
  59. data/app/views/gold/billing/suspended.html.erb +8 -0
  60. data/app/views/gold/billing/terms.html.erb +22 -0
  61. data/app/views/gold/billing/tier.html.erb +29 -0
  62. data/app/views/gold/billing/transition_error.html.erb +9 -0
  63. data/app/views/gold/billing/unavailable.erb +8 -0
  64. data/app/views/gold/billing/uninstalled.html.erb +13 -0
  65. data/app/views/gold/billing_mailer/affiliate_to_paid.erb +23 -0
  66. data/app/views/gold/billing_mailer/delinquent.html.erb +21 -0
  67. data/app/views/gold/billing_mailer/suspension.html.erb +19 -0
  68. data/app/views/layouts/gold/billing.html.erb +22 -0
  69. data/app/views/layouts/gold/mailer.html.erb +13 -0
  70. data/app/views/layouts/gold/mailer.text.erb +1 -0
  71. data/config/routes.rb +33 -0
  72. data/db/migrate/01_create_gold_billing.rb +11 -0
  73. data/db/migrate/02_create_gold_transitions.rb +29 -0
  74. data/db/migrate/03_add_foreign_key_to_billing.rb +8 -0
  75. data/lib/gold/admin_engine.rb +8 -0
  76. data/lib/gold/billing_migrator.rb +107 -0
  77. data/lib/gold/coverage.rb +89 -0
  78. data/lib/gold/diagram.rb +40 -0
  79. data/lib/gold/engine.rb +16 -0
  80. data/lib/gold/exceptions/metadata_missing.rb +5 -0
  81. data/lib/gold/outcomes.rb +77 -0
  82. data/lib/gold/retries.rb +31 -0
  83. data/lib/gold/version.rb +3 -0
  84. data/lib/gold.rb +102 -0
  85. data/lib/tasks/gold_tasks.rake +94 -0
  86. 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,13 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
5
+ <style>
6
+ /* Email styles need to be inline */
7
+ </style>
8
+ </head>
9
+
10
+ <body>
11
+ <%= yield %>
12
+ </body>
13
+ </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,8 @@
1
+ module Gold
2
+ # Brings the Gold to your app. Mount this in your config/routes.rb.
3
+ class AdminEngine < ::Rails::Engine
4
+ isolate_namespace Gold
5
+ config.autoload_paths << File.expand_path("../../lib", __dir__)
6
+ engine_name "gold_admin_engine"
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
@@ -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
@@ -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,5 @@
1
+ module Gold
2
+ module Exceptions
3
+ class MetadataMissing < StandardError; end
4
+ end
5
+ 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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Gold
2
+ VERSION = "2.0.0.pre".freeze
3
+ end