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,157 @@
1
+ module Gold
2
+ class UnknownTier < StandardError; end
3
+
4
+ # Class that tracks all billing details. Intended to be associated with a shop
5
+ # class that the app uses
6
+ class Billing < ApplicationRecord
7
+ include Statesman::Adapters::ActiveRecordQueries
8
+
9
+ class << self
10
+ def transition_class
11
+ Gold::Transition
12
+ end
13
+
14
+ def initial_state
15
+ :new
16
+ end
17
+
18
+ # Finds a billing instance for a particular shop, using the shop's domain
19
+ # as the key to look it up. Because this is not stored in the Billing
20
+ # model, a join against the shop association is required.
21
+ def lookup_for_domain!(domain)
22
+ # This is a little bit hard to follow, but `#joins` uses the name of the
23
+ # association as defined below as "shop" and `#where` expects the name
24
+ # of the associated table, which we can query from the class using
25
+ # `#table_name`.
26
+ joins(:shop)
27
+ .where(
28
+ Gold.shop_class.table_name => {
29
+ Gold.shop_domain_attribute => domain
30
+ }
31
+ )
32
+ .first!
33
+ end
34
+ end
35
+
36
+ # rubocop:disable Rails/ReflectionClassName
37
+ belongs_to :shop, class_name: Gold.shop_class.to_s
38
+ has_many :transitions, class_name: transition_class.to_s,
39
+ autosave: false,
40
+ dependent: :destroy
41
+ # rubocop:enable Rails/ReflectionClassName
42
+
43
+ def state_machine
44
+ transition_class = self.class.transition_class
45
+ @state_machine ||= Machine.new(self, transition_class: transition_class,
46
+ association_name: :transitions)
47
+ end
48
+
49
+ delegate :can_transition_to?,
50
+ :transition_to!,
51
+ :transition_to,
52
+ :transition_to_or_stay_in!,
53
+ :transition_to_or_stay_in,
54
+ :current_state,
55
+ to: :state_machine
56
+
57
+ def tier
58
+ @tier ||= Tier.find(tier_id)
59
+ end
60
+
61
+ def tier=(tier)
62
+ @tier = tier
63
+ self[:tier_id] = tier.id
64
+ end
65
+
66
+ def last_selected_tier
67
+ @last_selected_tier ||= find_last_selected_tier
68
+ end
69
+
70
+ # Returns the Shopify plan for this shop.
71
+ def shopify_plan
72
+ if shopify_plan_override
73
+ ShopifyPlan.new(shopify_plan_override)
74
+ elsif shop
75
+ ShopifyPlan.new(shop.shopify_plan_name)
76
+ else
77
+ ShopifyPlan.new(ShopifyAPI::Shop.current.plan_name)
78
+ end
79
+ end
80
+
81
+ def calculate_trial_days(tier, now = Time.current)
82
+ starts_at = trial_starts_at || now
83
+ trial_ends_at = starts_at.advance(days: tier.trial_days)
84
+ trial_period = trial_ends_at - now
85
+ if trial_period > 0
86
+ (trial_period / 1.day).ceil
87
+ else
88
+ 0
89
+ end
90
+ end
91
+
92
+ def qualifies_for_tier?(tier)
93
+ shop ? shop.qualifies_for_tier?(tier) : true
94
+ end
95
+
96
+ def trial_days_left
97
+ calculate_trial_days(tier)
98
+ end
99
+
100
+ def calculate_price(tier)
101
+ tier.monthly_price * (100 - discount_percentage) / 100
102
+ end
103
+
104
+ # Provides a way to let Gold know that the shop has changed. Your app should call this
105
+ # whenever a new shop update webhook is received or is manually overridden
106
+ def after_shop_update!
107
+ return_url = Engine.routes.url_helpers.process_charge_url
108
+
109
+ shop.with_shopify_session do
110
+ case current_state
111
+ when :affiliate
112
+ if shopify_plan.paying?
113
+ # If an affiliate has converted, change them to a paid plan
114
+ ConvertAffiliateToPaidOp.new(self, return_url).call
115
+ end
116
+ when :billing
117
+ # If their plan has been frozen, move to frozen state
118
+ FreezeOp.new(self).call if can_transition_to?(:frozen)
119
+ when :frozen
120
+ CheckChargeOp.new(self).call unless shopify_plan.frozen?
121
+ end
122
+ end
123
+ end
124
+
125
+ protected
126
+
127
+ # Returns the Shopify-specific properties for the current shop.
128
+ def shopify_properties
129
+ @shopify_properties ||= ShopifyAPI::Shop.current
130
+ end
131
+
132
+ def find_last_selected_tier
133
+ transition = state_machine.last_transition_to(
134
+ :select_tier,
135
+ :change_tier,
136
+ :optional_charge_declined,
137
+ :auto_upgrade_tier
138
+ )
139
+
140
+ raise UnknownTier.new, "Billing '#{id}' never selected a tier" unless transition
141
+
142
+ selected_tier = if transition.to_state.to_sym == :optional_charge_declined
143
+ # If the latest transition is a declined charge, fallback to
144
+ # the original tier
145
+ tier_id
146
+ else
147
+ transition.metadata["tier_id"]
148
+ end
149
+
150
+ unless (last_tier = Tier.find(selected_tier))
151
+ raise UnknownTier.new, "Billing '#{id}' tier '#{tier_id}' is undefined"
152
+ end
153
+
154
+ last_tier
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,22 @@
1
+ require_dependency "statesman"
2
+
3
+ module Gold
4
+ module Concerns
5
+ # This adds all of the necessary bits to an application's shop class for
6
+ # Gold to work properly.
7
+ #
8
+ # Steps to install:
9
+ #
10
+ # 1) Include `Gold::Concerns::Gilded` in the Shop class,
11
+ # 2) Run `rake gold:install:migrations`
12
+ # 3) Run `rake db:migrate`
13
+ #
14
+ module Gilded
15
+ extend ActiveSupport::Concern
16
+
17
+ included do
18
+ has_one :billing, class_name: "Gold::Billing", dependent: :destroy
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,301 @@
1
+ require_dependency "statesman"
2
+
3
+ module Gold
4
+ # This is the finite state machine specification that governs how shops may
5
+ # transition from one state to another.
6
+ class Machine
7
+ include Statesman::Machine
8
+
9
+ # rubocop:disable Metrics/LineLength
10
+
11
+ # States
12
+
13
+ state :new, initial: true
14
+
15
+ # Before merchants can use our app, they will need to review and accept our
16
+ # Terms of Service.
17
+ state :accepted_terms
18
+
19
+ # Merchants will need to select a plan/tier that meets their needs upon
20
+ # installing. They can also change their tier later. The new desired tier
21
+ # won't be applied until in :apply_tier.
22
+ state :select_tier
23
+ state :apply_tier
24
+ state :apply_free_tier
25
+
26
+ # When a paid store initially installs the app, they will have to accept
27
+ # billing charges before being able to use the app.
28
+ state :charge_activated
29
+ state :sudden_charge
30
+ state :sudden_charge_accepted
31
+ state :sudden_charge_expired
32
+ state :sudden_charge_declined
33
+
34
+ # Billing is where most stores will be. All of the necessary charges have
35
+ # been set up, the store is in good standing, etc.
36
+ state :billing
37
+
38
+ # We might choose to give merchants a one-time credit towards their bill
39
+ # for our app.
40
+ state :issue_credit
41
+
42
+ # We might choose to give merchants a discount on our app.
43
+ state :apply_discount
44
+
45
+ # A merchant might want to change the tier that they are on.
46
+ state :change_tier
47
+
48
+ # On a daily basis, we want to sync with Shopify to make sure a store's
49
+ # charge exists on their end. If we missed a webhook, they could have
50
+ # cancelled the charge somehow, but still be getting service from us.
51
+ state :check_charge
52
+ state :charge_missing
53
+
54
+ # Periodically, we will evaluate a store's usage of our app to see if they
55
+ # belong in a different tier. We may move them between tiers automatically.
56
+ state :calculate_usage
57
+ state :auto_upgrade_tier
58
+
59
+ # If a merchant changes tiers manually, they will be presented with new
60
+ # billing charges. If they accept these, the new tier will be applied. If
61
+ # they don't accept the new charges, they will stay on their existing tier.
62
+ state :optional_charge
63
+ state :optional_charge_declined
64
+ state :optional_charge_accepted
65
+
66
+ # If we automatically shift a merchant to a new tier, give them a sustained
67
+ # discount, they move from an affiliate to paid Shopify plan, etc, we will
68
+ # need to adjust their billing charges. This is similar to when they install
69
+ # the app at first, but we include a grace period to let them accept the new
70
+ # charges without immediately shutting off their use of our app.
71
+ state :delayed_charge
72
+ state :delayed_charge_accepted
73
+ state :delayed_charge_expired
74
+ state :delayed_charge_declined
75
+
76
+ state :marked_as_delinquent
77
+ state :delinquent
78
+
79
+ # Affiliate (development) stores are free stores that are not yet
80
+ # accessible to the world. They are created by Shopify partners and handed
81
+ # off to merchants when they go live. They will become paying stores with
82
+ # that transition.
83
+ state :affiliate
84
+ state :affiliate_to_paid
85
+
86
+ # Shopify staff may want to try our app. We provide it to them for free.
87
+ state :staff
88
+
89
+ # If a store fails to comply with our terms of service, we can mark them as
90
+ # suspended.
91
+ state :marked_as_suspended
92
+ state :suspended
93
+
94
+ # Shopify may freeze a store if they fail to pay in a timely manner. We will
95
+ # learn about this through webhooks and suspend their service accordingly.
96
+ state :frozen
97
+
98
+ # Users may choose to uninstall our app or not respond to us in a timely
99
+ # manner, in which case their use of the app will be revoked and eventually
100
+ # cleaned up.
101
+ state :marked_as_uninstalled
102
+ state :uninstalled
103
+ state :reinstalled
104
+ state :cleanup
105
+ state :done
106
+
107
+ # These are stable states that a store can be in for an indefinite amount of
108
+ # time. Any watchdog task should ignore these states.
109
+ ACCEPTANCE_STATES = %i[affiliate billing done staff].freeze
110
+
111
+ # Whether this state machine is currently in an acceptance state.
112
+ def accepted?
113
+ ACCEPTANCE_STATES.include?(current_state.to_sym)
114
+ end
115
+
116
+ def current_state
117
+ super.to_sym
118
+ end
119
+
120
+ # Guards
121
+
122
+ # Ensure that a shop has a certain type of plan before allowing a transition.
123
+ def self.ensure_plan_is(query_method)
124
+ test = proc { |billing| billing.shopify_plan.send(query_method) }
125
+ test.define_singleton_method(:to_s) { "Billing is #{query_method}" }
126
+ test
127
+ end
128
+
129
+ # Ensure that a shop has been in a current state for longer than number of days
130
+ def self.ensure_min_days_in_state(duration_in_days)
131
+ test = proc { |billing|
132
+ billing.state_machine.last_transition.updated_at < duration_in_days.days.ago
133
+ }
134
+ test.define_singleton_method(:to_s) { "After #{duration_in_days} days?" }
135
+ test
136
+ end
137
+
138
+ # Ensure that certain metadata properties are set before allowing a
139
+ # transition.
140
+ def self.require_metadata(*properties)
141
+ proc do |_, transition|
142
+ properties.each do |property|
143
+ # rubocop:disable Style/Next
144
+ unless transition.metadata.key?(property.to_sym) ||
145
+ transition.metadata.key?(property.to_s)
146
+ message = "Transition to #{transition.to_state} needs to have #{property} set"
147
+ raise Gold::Exceptions::MetadataMissing, message
148
+ end
149
+ # rubocop:enable Style/Next
150
+ end
151
+ end
152
+ end
153
+
154
+ # Transitions
155
+
156
+ transition from: :new, to: %i[accepted_terms marked_as_uninstalled]
157
+ transition from: :accepted_terms, to: %i[select_tier marked_as_uninstalled]
158
+
159
+ before_transition to: :select_tier, &require_metadata(:tier_id)
160
+ transition from: :select_tier, to: %i[affiliate
161
+ apply_free_tier
162
+ sudden_charge
163
+ select_tier
164
+ marked_as_uninstalled
165
+ staff]
166
+
167
+ before_transition to: :sudden_charge, &require_metadata(:charge_id)
168
+ guard_transition to: :sudden_charge, &ensure_plan_is(:paying?)
169
+ transition from: :sudden_charge, to: %i[sudden_charge_accepted
170
+ sudden_charge_declined
171
+ sudden_charge_expired
172
+ select_tier
173
+ marked_as_uninstalled]
174
+ transition from: :sudden_charge_accepted, to: %i[charge_activated
175
+ marked_as_uninstalled]
176
+ transition from: :sudden_charge_expired, to: :sudden_charge
177
+ transition from: :sudden_charge_declined, to: %i[sudden_charge select_tier]
178
+
179
+ transition from: :charge_activated, to: :apply_tier
180
+ before_transition to: :apply_tier, &require_metadata(:tier_id)
181
+ transition from: :apply_tier, to: :billing
182
+ before_transition to: :apply_free_tier, &require_metadata(:tier_id)
183
+ transition from: :apply_free_tier, to: :billing
184
+
185
+ transition from: :billing, to: %i[apply_discount
186
+ calculate_usage
187
+ change_tier
188
+ check_charge
189
+ frozen
190
+ issue_credit
191
+ marked_as_suspended
192
+ marked_as_uninstalled]
193
+
194
+ transition from: :check_charge, to: %i[check_charge
195
+ billing
196
+ charge_missing
197
+ frozen
198
+ marked_as_uninstalled]
199
+ guard_transition from: :check_charge,
200
+ to: :billing,
201
+ &ensure_plan_is(:paying?)
202
+
203
+ transition from: :charge_missing, to: %i[delayed_charge marked_as_uninstalled]
204
+
205
+ transition from: :calculate_usage, to: %i[auto_upgrade_tier billing]
206
+
207
+ before_transition to: :auto_upgrade_tier, &require_metadata(:tier_id)
208
+ transition from: :auto_upgrade_tier, to: :delayed_charge
209
+ transition from: :apply_discount, to: %i[optional_charge billing apply_discount]
210
+ transition from: :issue_credit, to: %i[billing marked_as_uninstalled]
211
+
212
+ before_transition to: :change_tier, &require_metadata(:tier_id)
213
+ transition from: :change_tier, to: %i[affiliate
214
+ apply_free_tier
215
+ change_tier
216
+ staff
217
+ optional_charge]
218
+
219
+ before_transition to: :optional_charge, &require_metadata(:charge_id)
220
+ guard_transition to: :optional_charge, &ensure_plan_is(:paying?)
221
+ transition from: :optional_charge, to: %i[change_tier
222
+ optional_charge_accepted
223
+ optional_charge_declined
224
+ marked_as_uninstalled]
225
+ transition from: :optional_charge_declined, to: :billing
226
+
227
+ transition from: :optional_charge_accepted, to: %i[charge_activated
228
+ marked_as_uninstalled]
229
+
230
+ before_transition to: :delayed_charge, &require_metadata(:charge_id)
231
+ guard_transition to: :delayed_charge, &ensure_plan_is(:paying?)
232
+ transition from: :delayed_charge, to: %i[marked_as_delinquent
233
+ select_tier
234
+ delayed_charge_accepted
235
+ delayed_charge_declined
236
+ delayed_charge_expired
237
+ marked_as_uninstalled]
238
+
239
+ transition from: :delayed_charge_accepted, to: %i[charge_activated
240
+ marked_as_uninstalled]
241
+
242
+ transition from: :delayed_charge_expired, to: :delayed_charge
243
+ transition from: :delayed_charge_declined, to: %i[marked_as_delinquent
244
+ delayed_charge]
245
+
246
+ guard_transition to: :marked_as_delinquent,
247
+ &ensure_min_days_in_state(Gold.configuration.days_until_delinquent)
248
+ transition from: :marked_as_delinquent, to: :delinquent
249
+ transition from: :delinquent, to: %i[delayed_charge cleanup]
250
+
251
+ guard_transition to: :affiliate, &ensure_plan_is(:affiliate?)
252
+ transition from: :affiliate, to: %i[affiliate_to_paid
253
+ change_tier
254
+ marked_as_suspended
255
+ marked_as_uninstalled]
256
+ transition from: :affiliate_to_paid, to: %i[apply_free_tier
257
+ delayed_charge]
258
+
259
+ guard_transition to: :staff, &ensure_plan_is(:staff?)
260
+ transition from: :staff, to: %i[change_tier marked_as_uninstalled]
261
+
262
+ guard_transition to: :frozen, &ensure_plan_is(:frozen?)
263
+ transition from: :frozen, to: %i[check_charge
264
+ cleanup
265
+ marked_as_uninstalled]
266
+
267
+ transition from: :marked_as_suspended, to: :suspended
268
+ transition from: :suspended, to: %i[affiliate
269
+ check_charge
270
+ cleanup]
271
+
272
+ transition from: :marked_as_uninstalled, to: %i[uninstalled]
273
+ transition from: :uninstalled, to: %i[cleanup reinstalled]
274
+ transition from: :reinstalled, to: %i[select_tier
275
+ marked_as_uninstalled]
276
+
277
+ guard_transition to: :cleanup, &ensure_min_days_in_state(Gold.configuration.days_until_cleanup)
278
+ transition from: :cleanup, to: :done
279
+ transition from: :done, to: :reinstalled
280
+
281
+ # rubocop:enable Metrics/LineLength
282
+
283
+ # Helpers
284
+
285
+ # Like `last_transition` but returns the last transition to one of the states
286
+ # provided as arguments. Returns nil if no transitions match.
287
+ def last_transition_to(*states)
288
+ history.reverse.find { |t| states.include?(t.to_state.to_sym) }
289
+ end
290
+
291
+ # Transition only if different from the current state
292
+ def transition_to_or_stay_in!(state, metadata = nil)
293
+ transition_to!(state, metadata) unless current_state == state
294
+ end
295
+
296
+ # Soft transition only if different from the current state
297
+ def transition_to_or_stay_in(state, metadata = nil)
298
+ current_state == state ? true : transition_to(state, metadata)
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,70 @@
1
+ module Gold
2
+ # Describes a Shopify plan that a shop is tied to. This may change over the
3
+ # lifetime of the shop. While this is a relatively simple wrapper around the
4
+ # plan name string, it provides some nice query methods.
5
+ class ShopifyPlan
6
+ def initialize(plan)
7
+ @plan = plan
8
+ end
9
+
10
+ def to_s
11
+ plan
12
+ end
13
+
14
+ def ==(other)
15
+ plan == other.plan
16
+ end
17
+ alias eql? ==
18
+
19
+ delegate :hash, to: :plan
20
+
21
+ # Returns true if this is a development (non-live) shop.
22
+ def affiliate?
23
+ plan == "affiliate"
24
+ end
25
+
26
+ # Returns true if this is a shop owned by Shopify staff. This specifically
27
+ # excludes Shopify Business shops, as we believe those are paid stores that
28
+ # Shopify employees use for their own businesses.
29
+ def staff?
30
+ plan == "staff"
31
+ end
32
+
33
+ # Returns true if this shop has been frozen by Shopify for non-payment.
34
+ def frozen?
35
+ plan == "frozen"
36
+ end
37
+
38
+ # Returns true if this shop has cancelled their Shopify subscription.
39
+ def cancelled?
40
+ plan == "cancelled"
41
+ end
42
+
43
+ # Returns true if this shop is currently paused by the merchant. Their
44
+ # online store is not accessible in this situation.
45
+ def dormant?
46
+ plan == "dormant"
47
+ end
48
+
49
+ # Returns whether this shop is on a paid plan and is in good standing
50
+ # currently. A paying shop is able to accept charges.
51
+ def paying?
52
+ !(affiliate? || staff? || frozen? || cancelled?)
53
+ end
54
+
55
+ # Returns whether this shop should be allowed to create an account
56
+ # currently.
57
+ def good?
58
+ !bad?
59
+ end
60
+
61
+ # The opposite of `#good?`
62
+ def bad?
63
+ frozen? || cancelled?
64
+ end
65
+
66
+ protected
67
+
68
+ attr_reader :plan
69
+ end
70
+ end
@@ -0,0 +1,97 @@
1
+ require "bigdecimal"
2
+ require "yaml"
3
+
4
+ module Gold
5
+ # A tier is level of service that this app offer merchants. Tiers are named,
6
+ # have a series of available features, and have pricing data. Tiers are
7
+ # configured in an app's config/tiers.yml file.
8
+ class Tier
9
+ CONFIG_FILE = Rails.root.join("config", "tiers.yml")
10
+
11
+ class << self
12
+ def load_from_yaml
13
+ YAML.load_file(CONFIG_FILE)
14
+ end
15
+
16
+ # Returns the defined tiers.
17
+ def all
18
+ @all ||= (load_from_yaml || []).map { |tier| Tier.new(tier) }
19
+ end
20
+
21
+ # Returns only the visible tiers (ones that customers should see on a
22
+ # pricing comparision page).
23
+ def visible
24
+ all.find_all(&:visible?)
25
+ end
26
+
27
+ # Returns the tier by ID or nil if that tier cannot be found.
28
+ def find(id)
29
+ return nil if id.nil?
30
+
31
+ all.find { |tier| tier.id == id.to_sym }
32
+ end
33
+
34
+ # Exposed for testing to allow clearing the cached tiers. Not necessary
35
+ # for normal application use.
36
+ def reset!
37
+ @all = nil
38
+ end
39
+ end
40
+
41
+ attr_reader :id,
42
+ :name,
43
+ :description,
44
+ :trial_days,
45
+ :monthly_price
46
+
47
+ def initialize(options = {})
48
+ raise ArgumentError, "Cannot parse tier: #{options}" unless options.is_a?(Hash)
49
+ raise ArgumentError, "ID must be provided for a tier" unless options["id"]
50
+
51
+ @id = options["id"].to_sym
52
+ @parent_id = options["parent_id"]
53
+ @name = options["name"]
54
+ @description = options["description"]
55
+ @locked = options.fetch("locked", false)
56
+ @visible = options.fetch("visible", true)
57
+ @trial_days = options.dig("pricing", "trial_days") || 0
58
+ @monthly_price = BigDecimal(options.dig("pricing", "monthly") || 0)
59
+ @free = options.dig("pricing", "free") || false
60
+ @qualifications = (options["qualifications"] || {}).with_indifferent_access
61
+ @features = (options["features"] || {}).with_indifferent_access
62
+ end
63
+
64
+ # Whether the tier has been locked and new shops can no longer user it
65
+ # without an admin assigning it to their shop.
66
+ def locked?
67
+ @locked
68
+ end
69
+
70
+ # Whether the tier should show up in a list of available options.
71
+ def visible?
72
+ @visible
73
+ end
74
+
75
+ # Whether the tier is completely free for shops to use. This is different
76
+ # than a tier that has a zero monthly price but usage-based charges.
77
+ def free?
78
+ @free
79
+ end
80
+
81
+ def qualifications
82
+ parent ? parent.qualifications : @qualifications
83
+ end
84
+
85
+ def features
86
+ parent ? parent.features : @features
87
+ end
88
+
89
+ def parent
90
+ @parent_id ? self.class.find(@parent_id.to_sym) : nil
91
+ end
92
+
93
+ def top_id
94
+ parent ? parent.id : id
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,26 @@
1
+ module Gold
2
+ # Stores transitions between states in Gold::Machine.
3
+ class Transition < ApplicationRecord
4
+ # If your transition table doesn't have the default `updated_at` timestamp column,
5
+ # you'll need to configure the `updated_timestamp_column` option, setting it to
6
+ # another column name (e.g. `:updated_on`) or `nil`.
7
+ #
8
+ # self.updated_timestamp_column = :updated_on
9
+ # self.updated_timestamp_column = nil
10
+
11
+ belongs_to :billing, inverse_of: :transitions
12
+
13
+ after_destroy :update_most_recent, if: :most_recent?
14
+
15
+ private
16
+
17
+ def update_most_recent
18
+ last_transition = billing.transitions.order(:sort_key).last
19
+ return if last_transition.blank?
20
+
21
+ # rubocop:disable Rails/SkipsModelValidations
22
+ last_transition.update_column(:most_recent, true)
23
+ # rubocop:enable Rails/SkipsModelValidations
24
+ end
25
+ end
26
+ end