shopify-gold 2.0.0.pre

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