shopify-gold 2.0.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- 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
|