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,119 @@
|
|
1
|
+
module Gold
|
2
|
+
module Outcomes
|
3
|
+
DeclinedCharge = Class.new(Failure)
|
4
|
+
CannotProcessCharge = Class.new(Failure)
|
5
|
+
end
|
6
|
+
|
7
|
+
# The charge was accepted or denied by the merchant.
|
8
|
+
class AcceptOrDeclineChargeOp
|
9
|
+
include Outcomes
|
10
|
+
include Retries
|
11
|
+
|
12
|
+
def initialize(billing, charge_id)
|
13
|
+
@billing = billing
|
14
|
+
@charge_id = charge_id.to_i
|
15
|
+
@charge_transition = @billing.state_machine.last_transition_to(
|
16
|
+
:sudden_charge,
|
17
|
+
:delayed_charge,
|
18
|
+
:optional_charge
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def call
|
23
|
+
unless @charge_transition
|
24
|
+
Gold.logger.warn("[#{@billing.id}] Cannot find any charge creation events")
|
25
|
+
return CannotProcessCharge.new(:not_found)
|
26
|
+
end
|
27
|
+
|
28
|
+
expected_charge_id = @charge_transition.metadata["charge_id"]
|
29
|
+
|
30
|
+
if expected_charge_id != @charge_id
|
31
|
+
Gold.logger.warn(
|
32
|
+
"[#{@billing.id}] Expecting charge to be #{expected_charge_id.inspect} " \
|
33
|
+
"but it was #{@charge_id.inspect}"
|
34
|
+
)
|
35
|
+
return CannotProcessCharge.new(:old_charge)
|
36
|
+
end
|
37
|
+
|
38
|
+
charge = find_charge
|
39
|
+
|
40
|
+
if !charge
|
41
|
+
CannotProcessCharge.new(:not_found)
|
42
|
+
elsif charge.status == "active"
|
43
|
+
Gold.logger.info("[#{@billing.id}] This charge has already been activated")
|
44
|
+
@billing.transition_to!(accepted_state)
|
45
|
+
@billing.transition_to!(activated_state)
|
46
|
+
return ActiveCharge.new(@charge_id)
|
47
|
+
elsif charge.status == "accepted"
|
48
|
+
@billing.transition_to!(accepted_state)
|
49
|
+
activate_charge(charge)
|
50
|
+
elsif charge.status == "declined"
|
51
|
+
case @charge_transition.to_state.to_sym
|
52
|
+
when :sudden_charge
|
53
|
+
@billing.transition_to!(:sudden_charge_declined)
|
54
|
+
when :delayed_charge
|
55
|
+
@billing.transition_to!(:delayed_charge_declined)
|
56
|
+
when :optional_charge
|
57
|
+
# Also see FindOutstandingCharge, as it does the same logic
|
58
|
+
@billing.transition_to_or_stay_in!(:optional_charge_declined)
|
59
|
+
@billing.transition_to!(:billing)
|
60
|
+
end
|
61
|
+
|
62
|
+
DeclinedCharge.new
|
63
|
+
elsif charge.status == "expired"
|
64
|
+
@billing.transition_to!(expired_state)
|
65
|
+
ExpiredCharge.new
|
66
|
+
else
|
67
|
+
Gold.logger.warn("[#{@billing.id}] Charge failed '#{charge.status}'")
|
68
|
+
CannotProcessCharge.new(:bad_state)
|
69
|
+
end
|
70
|
+
rescue ActiveResource::ForbiddenAccess
|
71
|
+
@billing.transition_to!(:marked_as_uninstalled)
|
72
|
+
Outcomes::Uninstalled.new
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def accepted_state
|
78
|
+
case @charge_transition.to_state.to_sym
|
79
|
+
when :sudden_charge then :sudden_charge_accepted
|
80
|
+
when :delayed_charge then :delayed_charge_accepted
|
81
|
+
when :optional_charge then :optional_charge_accepted
|
82
|
+
else
|
83
|
+
raise "Don't know what to do with #{@charge_transition.to_state}"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def activated_state
|
88
|
+
:charge_activated
|
89
|
+
end
|
90
|
+
|
91
|
+
def expired_state
|
92
|
+
case @charge_transition.to_state.to_sym
|
93
|
+
when :sudden_charge then :sudden_charge_expired
|
94
|
+
when :delayed_charge then :delayed_charge_expired
|
95
|
+
when :optional_charge then :optional_charge_declined
|
96
|
+
else
|
97
|
+
raise "Don't know what to do with #{@charge_transition.to_state}"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def find_charge
|
102
|
+
request_with_retries do
|
103
|
+
ShopifyAPI::RecurringApplicationCharge.find(@charge_id)
|
104
|
+
end
|
105
|
+
rescue ActiveResource::ResourceNotFound
|
106
|
+
Gold.logger.warn("[#{@billing.id}] failed to retrieve charge from Shopify")
|
107
|
+
nil
|
108
|
+
end
|
109
|
+
|
110
|
+
# Consumes a charge and activates it
|
111
|
+
def activate_charge(charge)
|
112
|
+
request_with_retries do
|
113
|
+
charge.activate
|
114
|
+
@billing.transition_to!(activated_state)
|
115
|
+
return ActiveCharge.new(charge.id)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Gold
|
2
|
+
module Outcomes
|
3
|
+
AcceptedTerms = Class.new(Success)
|
4
|
+
DeclinedTerms = Class.new(Failure)
|
5
|
+
end
|
6
|
+
|
7
|
+
# Mark a store as having accepted/denied our Terms of Service.
|
8
|
+
class AcceptOrDeclineTermsOp
|
9
|
+
include Outcomes
|
10
|
+
|
11
|
+
def initialize(billing, accepted)
|
12
|
+
@billing = billing
|
13
|
+
@accepted = accepted
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
if @accepted
|
18
|
+
@billing.transition_to!(:accepted_terms)
|
19
|
+
|
20
|
+
return AcceptedTerms.new
|
21
|
+
end
|
22
|
+
|
23
|
+
DeclinedTerms.new
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Gold
|
2
|
+
module Outcomes
|
3
|
+
CannotApplyDiscount = Class.new(Failure)
|
4
|
+
SameDiscount = Class.new(Success)
|
5
|
+
end
|
6
|
+
|
7
|
+
# Applies a discount to a shop and creates a new charge for them.
|
8
|
+
class ApplyDiscountOp
|
9
|
+
include Outcomes
|
10
|
+
|
11
|
+
def initialize(billing, percentage, return_url, test = false)
|
12
|
+
@billing = billing
|
13
|
+
@percentage = percentage.to_i
|
14
|
+
@return_url = return_url
|
15
|
+
@test = test
|
16
|
+
end
|
17
|
+
|
18
|
+
def call
|
19
|
+
# Validate the discount
|
20
|
+
if @percentage < 0 || @percentage >= 100
|
21
|
+
return CannotApplyDiscount.new(:invalid_percentage)
|
22
|
+
end
|
23
|
+
|
24
|
+
return SameDiscount.new if @percentage == @billing.discount_percentage
|
25
|
+
|
26
|
+
@billing.transition_to!(:apply_discount, percentage: @percentage)
|
27
|
+
@billing.update(discount_percentage: @percentage)
|
28
|
+
|
29
|
+
ChargeOp.new(@billing, @return_url, @test).call
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Gold
|
2
|
+
# Apply the tier that the shop has selected.
|
3
|
+
class ApplyTierOp
|
4
|
+
include Outcomes
|
5
|
+
include Retries
|
6
|
+
|
7
|
+
def initialize(billing)
|
8
|
+
@billing = billing
|
9
|
+
end
|
10
|
+
|
11
|
+
def call
|
12
|
+
begin
|
13
|
+
tier = @billing.last_selected_tier
|
14
|
+
rescue UnknownTier => e
|
15
|
+
return TierNotFound.new(e.message)
|
16
|
+
end
|
17
|
+
|
18
|
+
unless %i[apply_tier apply_free_tier].include?(@billing.current_state)
|
19
|
+
if tier.free?
|
20
|
+
@billing.transition_to!(:apply_free_tier, tier_id: tier.id)
|
21
|
+
else
|
22
|
+
@billing.transition_to!(:apply_tier, tier_id: tier.id)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
@billing.tier = tier
|
27
|
+
@billing.save!
|
28
|
+
|
29
|
+
if tier.free?
|
30
|
+
if Gold.configuration.allow_automated_charge_cancellation
|
31
|
+
# Remove any outstanding active charges
|
32
|
+
if (charge = ShopifyAPI::RecurringApplicationCharge.current)
|
33
|
+
request_with_retries do
|
34
|
+
Gold.logger.info("[#{@billing.id}] Free tier applied, canceling " \
|
35
|
+
"charge '#{charge.price}' for " \
|
36
|
+
"'#{@billing.shop.shopify_domain}'")
|
37
|
+
charge.cancel
|
38
|
+
end
|
39
|
+
end
|
40
|
+
else
|
41
|
+
Gold.logger.info("[#{@billing.id}] Would have cancelled charge for " \
|
42
|
+
"'#{@billing.shop.shopify_domain}', but prevented by setting")
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
@billing.transition_to!(:billing)
|
47
|
+
Gold.configuration.on_apply_tier&.call(@billing, tier.id)
|
48
|
+
TierApplied.new
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module Gold
|
2
|
+
# Creates a charge for a shop.
|
3
|
+
class ChargeOp
|
4
|
+
MAX_ATTEMPTS = 3
|
5
|
+
|
6
|
+
include Retries
|
7
|
+
include Outcomes
|
8
|
+
|
9
|
+
def initialize(billing, return_url, test_charge = Gold.configuration.test_charge?)
|
10
|
+
@billing = billing
|
11
|
+
@return_url = return_url
|
12
|
+
@test_charge = test_charge
|
13
|
+
end
|
14
|
+
|
15
|
+
# Find a previous charge to use or create a new one
|
16
|
+
def call
|
17
|
+
return ChargeNotNeeded.new(:tier_is_free) if tier.free?
|
18
|
+
return ChargeNotNeeded.new(:in_billing_state) if @billing.current_state == :billing
|
19
|
+
|
20
|
+
charges = ShopifyAPI::RecurringApplicationCharge.all
|
21
|
+
if (charge = find_matching_charge(charges, @billing, "active"))
|
22
|
+
Gold.logger.info("[#{@billing.id}] There is an existing charge (#{charge.id}) " \
|
23
|
+
"ready to use; skip charge creation")
|
24
|
+
|
25
|
+
transition_to_charge_state!(charge_id: charge.id)
|
26
|
+
return ActiveCharge.new(charge.id)
|
27
|
+
elsif (charge = find_matching_charge(charges, @billing, "accepted"))
|
28
|
+
Gold.logger.info("[#{@billing.id}] There is an accepted charge (#{charge.id}) " \
|
29
|
+
"that we haven't registered yet; redirecting to that now")
|
30
|
+
|
31
|
+
# Log that this charge is the one we want to use
|
32
|
+
transition_to_charge_state!(charge_id: charge.id)
|
33
|
+
return AcceptedCharge.new(charge.decorated_return_url)
|
34
|
+
elsif (charge = find_matching_charge(charges, @billing, "pending"))
|
35
|
+
Gold.logger.info("[#{@billing.id}] Reusing an existing charge: #{charge.id}")
|
36
|
+
|
37
|
+
# Mark the charge as ready for the merchant to review
|
38
|
+
transition_to_charge_state!(charge_id: charge.id)
|
39
|
+
return PendingCharge.new(charge.confirmation_url)
|
40
|
+
else
|
41
|
+
# Construct a new RecurringApplicationCharge for the tier
|
42
|
+
charge = build_charge(@billing, tier)
|
43
|
+
|
44
|
+
request_with_retries do
|
45
|
+
charge.save!
|
46
|
+
end
|
47
|
+
|
48
|
+
# Mark the charge as ready for the merchant to review
|
49
|
+
transition_to_charge_state!(charge_id: charge.id)
|
50
|
+
|
51
|
+
return PendingCharge.new(charge.confirmation_url)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
protected
|
56
|
+
|
57
|
+
def tier
|
58
|
+
@billing.last_selected_tier
|
59
|
+
end
|
60
|
+
|
61
|
+
# Attempt to transition to :sudden_charge, then :delayed_charge, else fail
|
62
|
+
def transition_to_charge_state!(metadata)
|
63
|
+
if @billing.transition_to_or_stay_in(:sudden_charge, metadata)
|
64
|
+
elsif @billing.transition_to_or_stay_in(:delayed_charge, metadata)
|
65
|
+
elsif @billing.transition_to_or_stay_in(:optional_charge, metadata)
|
66
|
+
else
|
67
|
+
message = "Cannot transition from '#{@billing.current_state}' " \
|
68
|
+
"to 'delayed_charge', 'optional_charge', or 'sudden_charge'"
|
69
|
+
raise Statesman::TransitionFailedError, message
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def find_matching_charge(charges, billing, status)
|
74
|
+
charges.find do |charge|
|
75
|
+
charge.status == status &&
|
76
|
+
charge.price.to_d == billing.calculate_price(tier) &&
|
77
|
+
billing.trial_starts_at && # Trial must have already started
|
78
|
+
(status == "active" || charge.return_url == @return_url) &&
|
79
|
+
charge.test == @test_charge
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def build_charge(billing, tier)
|
84
|
+
name = "#{Gold.configuration.app_name} #{tier.name}"
|
85
|
+
|
86
|
+
if billing.discount_percentage > 0
|
87
|
+
name += " (#{billing.discount_percentage}% discount)"
|
88
|
+
end
|
89
|
+
|
90
|
+
ShopifyAPI::RecurringApplicationCharge.new(
|
91
|
+
name: name,
|
92
|
+
price: billing.calculate_price(tier),
|
93
|
+
return_url: @return_url,
|
94
|
+
test: @test_charge,
|
95
|
+
trial_days: billing.calculate_trial_days(tier)
|
96
|
+
)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Gold
|
2
|
+
module Outcomes
|
3
|
+
MismatchCharge = Class.new(Failure)
|
4
|
+
end
|
5
|
+
|
6
|
+
# Ensure that the charge is correct and active.
|
7
|
+
class CheckChargeOp
|
8
|
+
include Outcomes
|
9
|
+
include Retries
|
10
|
+
|
11
|
+
def initialize(billing, test_charge = Gold.configuration.test_charge?)
|
12
|
+
@billing = billing
|
13
|
+
@test_charge = test_charge
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
@billing.transition_to(:check_charge) unless @billing.current_state == :check_charge
|
18
|
+
|
19
|
+
charges = request_with_retries do
|
20
|
+
ShopifyAPI::RecurringApplicationCharge.all
|
21
|
+
end
|
22
|
+
|
23
|
+
active_charge = charges.find { |charge| charge.status == "active" }
|
24
|
+
frozen_charge = charges.find { |charge| charge.status == "frozen" }
|
25
|
+
|
26
|
+
if active_charge && billing_matches_charge?(active_charge)
|
27
|
+
@billing.transition_to!(:billing)
|
28
|
+
return ActiveCharge.new(active_charge.id)
|
29
|
+
elsif frozen_charge && @billing.transition_to(:frozen)
|
30
|
+
return FrozenCharge.new
|
31
|
+
elsif active_charge
|
32
|
+
@billing.transition_to!(:charge_missing)
|
33
|
+
return MismatchCharge.new
|
34
|
+
else
|
35
|
+
@billing.transition_to!(:charge_missing)
|
36
|
+
return MissingCharge.new
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def billing_matches_charge?(charge)
|
43
|
+
tier = @billing.last_selected_tier
|
44
|
+
|
45
|
+
@billing.calculate_price(tier) == charge.price.to_d && # Price must match
|
46
|
+
@billing.trial_starts_at && # Trial must have already started
|
47
|
+
@test_charge == charge.test
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Gold
|
2
|
+
# Clean up resources associated with a store after a merchant has uninstalled
|
3
|
+
# and a sufficient amount of time has passed.
|
4
|
+
class CleanupOp
|
5
|
+
include Outcomes
|
6
|
+
|
7
|
+
def initialize(billing)
|
8
|
+
@billing = billing
|
9
|
+
end
|
10
|
+
|
11
|
+
def call
|
12
|
+
@billing.transition_to_or_stay_in!(:cleanup)
|
13
|
+
|
14
|
+
Gold.configuration.on_cleanup&.call(@billing)
|
15
|
+
|
16
|
+
@billing.transition_to!(:done)
|
17
|
+
Success.new
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Gold
|
2
|
+
class ConversionError < StandardError; end
|
3
|
+
|
4
|
+
# Convert an affiliate store to a paid store.
|
5
|
+
class ConvertAffiliateToPaidOp
|
6
|
+
def initialize(billing, return_url, test_charge = Gold.configuration.test_charge?)
|
7
|
+
@billing = billing
|
8
|
+
@return_url = return_url
|
9
|
+
@test_charge = test_charge
|
10
|
+
end
|
11
|
+
|
12
|
+
def call
|
13
|
+
unless @billing.shopify_plan.paying?
|
14
|
+
raise ConversionError, "This is not a paying plan"
|
15
|
+
end
|
16
|
+
|
17
|
+
@billing.transition_to_or_stay_in!(:affiliate_to_paid)
|
18
|
+
|
19
|
+
if @billing.last_selected_tier.free?
|
20
|
+
ApplyTierOp.new(@billing).call
|
21
|
+
else
|
22
|
+
outcome = ChargeOp.new(@billing, @return_url, @test_charge).call
|
23
|
+
|
24
|
+
if outcome.is_a? Outcomes::PendingCharge
|
25
|
+
BillingMailer.affiliate_to_paid(@billing, outcome.confirmation_url).deliver_now
|
26
|
+
end
|
27
|
+
|
28
|
+
outcome
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Gold
|
2
|
+
# Invoked when Shopify suspends a merchant. This is the opposite of `ThawOp`.
|
3
|
+
class FreezeOp
|
4
|
+
include Outcomes
|
5
|
+
|
6
|
+
def initialize(billing)
|
7
|
+
@billing = billing
|
8
|
+
end
|
9
|
+
|
10
|
+
def call
|
11
|
+
@billing.transition_to_or_stay_in!(:frozen)
|
12
|
+
Success.new
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Gold
|
2
|
+
InstallFailed = Class.new(Outcomes::Failure)
|
3
|
+
|
4
|
+
# Invoked when a shop has installed (or reinstalled) this app.
|
5
|
+
class InstallOp
|
6
|
+
include Outcomes
|
7
|
+
|
8
|
+
def initialize(billing)
|
9
|
+
@billing = billing
|
10
|
+
end
|
11
|
+
|
12
|
+
def call
|
13
|
+
# If possible, transition to reinstalled
|
14
|
+
@billing.transition_to(:reinstalled)
|
15
|
+
|
16
|
+
case @billing.current_state
|
17
|
+
when :frozen
|
18
|
+
return CheckChargeOp.new(@billing).call
|
19
|
+
when :cleanup
|
20
|
+
Gold.logger.warn("[#{@billing.id}] Tried installing?" \
|
21
|
+
"'#{@billing.shop.shopify_domain}', but on cleanup")
|
22
|
+
return InstallFailed.new(:cleanup_in_progress)
|
23
|
+
end
|
24
|
+
|
25
|
+
Gold.configuration.on_install&.call(@billing)
|
26
|
+
|
27
|
+
Success.new
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module Gold
|
2
|
+
# Returned when a credit cannot be issued, likely due to a restriction on
|
3
|
+
# Shopify's side.
|
4
|
+
module Outcomes
|
5
|
+
CannotIssueCredit = Class.new(Failure)
|
6
|
+
end
|
7
|
+
|
8
|
+
# Provide a credit to a merchant.
|
9
|
+
class IssueCreditOp
|
10
|
+
MAX_ATTEMPTS = 3
|
11
|
+
|
12
|
+
include Outcomes
|
13
|
+
include Retries
|
14
|
+
|
15
|
+
def initialize(billing, amount, reason, test = false)
|
16
|
+
@billing = billing
|
17
|
+
@amount = amount.to_d
|
18
|
+
@reason = reason
|
19
|
+
@test = test
|
20
|
+
end
|
21
|
+
|
22
|
+
def call
|
23
|
+
return CannotIssueCredit.new(:amount_not_positive) if @amount <= 0
|
24
|
+
return CannotIssueCredit.new(:missing_reason) if @reason.blank?
|
25
|
+
return CannotIssueCredit.new(:invalid_tier) if @billing.last_selected_tier&.free?
|
26
|
+
|
27
|
+
@billing.transition_to!(:issue_credit, amount: @amount, reason: @reason)
|
28
|
+
|
29
|
+
# Now create the credit on Shopify's end
|
30
|
+
credit = new_credit
|
31
|
+
|
32
|
+
request_with_retries do
|
33
|
+
credit.save
|
34
|
+
end
|
35
|
+
|
36
|
+
# Mark the credit as applied
|
37
|
+
@billing.transition_to!(:billing)
|
38
|
+
|
39
|
+
if credit.valid?
|
40
|
+
Success.new
|
41
|
+
else
|
42
|
+
CannotIssueCredit.new(:rejected_by_shopify,
|
43
|
+
credit.errors.full_messages.join(", "))
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def new_credit
|
48
|
+
ShopifyAPI::ApplicationCredit.new(
|
49
|
+
description: @reason,
|
50
|
+
amount: @amount,
|
51
|
+
test: @test
|
52
|
+
)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Gold
|
2
|
+
# Invoked when Helium suspends a merchant for violating ToS.
|
3
|
+
class MarkAsDelinquentOp
|
4
|
+
include Outcomes
|
5
|
+
|
6
|
+
def initialize(billing)
|
7
|
+
@billing = billing
|
8
|
+
end
|
9
|
+
|
10
|
+
def call
|
11
|
+
@billing.transition_to_or_stay_in!(:marked_as_delinquent)
|
12
|
+
|
13
|
+
# Communicate delinquency to the merchant.
|
14
|
+
BillingMailer.delinquent(@billing).deliver_now
|
15
|
+
|
16
|
+
@billing.transition_to!(:delinquent)
|
17
|
+
|
18
|
+
Success.new
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
module Gold
|
2
|
+
# Look up billing information and see if they have a charge or not based on
|
3
|
+
# the charges we've previously created
|
4
|
+
class ResolveOutstandingChargeOp
|
5
|
+
include Outcomes
|
6
|
+
include Retries
|
7
|
+
|
8
|
+
def initialize(billing)
|
9
|
+
@billing = billing
|
10
|
+
end
|
11
|
+
|
12
|
+
def call
|
13
|
+
return MissingCharge.new(:no_charge_state) unless charge_state_transition
|
14
|
+
|
15
|
+
charge_id = charge_state_transition.metadata["charge_id"]
|
16
|
+
|
17
|
+
begin
|
18
|
+
charge = request_with_retries do
|
19
|
+
ShopifyAPI::RecurringApplicationCharge.find(charge_id)
|
20
|
+
end
|
21
|
+
rescue ActiveResource::ResourceNotFound
|
22
|
+
return MissingCharge.new(:shopify_missing)
|
23
|
+
end
|
24
|
+
|
25
|
+
case charge.status
|
26
|
+
when "active", "frozen"
|
27
|
+
return ActiveCharge.new(charge_id)
|
28
|
+
when "accepted"
|
29
|
+
return AcceptedCharge.new(charge.decorated_return_url)
|
30
|
+
when "pending"
|
31
|
+
return PendingCharge.new(charge.confirmation_url)
|
32
|
+
when "expired"
|
33
|
+
if sudden_charge?
|
34
|
+
@billing.transition_to!(:sudden_charge_expired)
|
35
|
+
return ExpiredCharge.new
|
36
|
+
elsif delayed_charge?
|
37
|
+
@billing.transition_to!(:delayed_charge_expired)
|
38
|
+
return ExpiredCharge.new
|
39
|
+
elsif optional_charge?
|
40
|
+
# It is OK for an optional charge to expire. Put the merchant back in
|
41
|
+
# a normal billing state and let them retry their charge later, if
|
42
|
+
# desired.
|
43
|
+
@billing.transition_to!(:optional_charge_declined)
|
44
|
+
@billing.transition_to!(:billing)
|
45
|
+
return ActiveCharge.new(charge_id)
|
46
|
+
end
|
47
|
+
@billing.transition_to!(expired_state)
|
48
|
+
return ExpiredCharge.new
|
49
|
+
when "declined"
|
50
|
+
if sudden_charge?
|
51
|
+
@billing.transition_to!(:sudden_charge_declined)
|
52
|
+
return MissingCharge.new(:charge_declined)
|
53
|
+
elsif delayed_charge?
|
54
|
+
@billing.transition_to!(:delayed_charge_declined)
|
55
|
+
return MissingCharge.new(:charge_declined)
|
56
|
+
elsif optional_charge?
|
57
|
+
@billing.transition_to_or_stay_in!(:optional_charge_declined)
|
58
|
+
@billing.transition_to!(:billing)
|
59
|
+
return ActiveCharge.new(charge_id)
|
60
|
+
end
|
61
|
+
when "cancelled"
|
62
|
+
return MissingCharge.new(:charge_cancelled)
|
63
|
+
else
|
64
|
+
raise MissingCharge.new(:bad_status, "Bad status: '#{charge.status}'")
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def charge_state_transition
|
71
|
+
@charge_state_transition ||=
|
72
|
+
@billing.state_machine.last_transition_to(:sudden_charge,
|
73
|
+
:delayed_charge,
|
74
|
+
:optional_charge)
|
75
|
+
end
|
76
|
+
|
77
|
+
def sudden_charge?
|
78
|
+
charge_state_transition.nil? ||
|
79
|
+
charge_state_transition.to_state.to_sym == :sudden_charge
|
80
|
+
end
|
81
|
+
|
82
|
+
def delayed_charge?
|
83
|
+
charge_state_transition.to_state.to_sym == :delayed_charge
|
84
|
+
end
|
85
|
+
|
86
|
+
def optional_charge?
|
87
|
+
charge_state_transition.to_state.to_sym == :optional_charge
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|