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