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