Last active
August 29, 2015 14:16
-
-
Save gabeodess/23cdb95c4e20b9426c15 to your computer and use it in GitHub Desktop.
Stubbing #save! and raising an exception with Mocha
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Subscription < ActiveRecord::Base | |
PRICING = { | |
1 => { | |
:small => 1300, | |
:medium => 1900, | |
:large => 2400 | |
}.with_indifferent_access, | |
2 => { | |
:small => 16500, | |
:medium => 24200, | |
:large => 30500 | |
}.with_indifferent_access, | |
3 => { | |
:small => 32100, | |
:medium => 46900, | |
:large => 59200 | |
}.with_indifferent_access, | |
4 => { | |
:small => 60800, | |
:medium => 88900, | |
:large => 112300 | |
}.with_indifferent_access | |
}.stringify_keys!.with_indifferent_access | |
SIZES = { | |
'small' => 1, | |
'medium' => 2, | |
'large' => 3 | |
}.with_indifferent_access | |
TIERS = { | |
1 => 1, | |
2 => 13, | |
3 => 26, | |
4 => 52 | |
}.stringify_keys!.with_indifferent_access | |
# ============== | |
# = Attributes = | |
# ============== | |
attr_accessor :card | |
delegate :email, :to => :user | |
# ========== | |
# = Scopes = | |
# ========== | |
scope :find_active, -> { where({:status => 'active'}) } | |
scope :find_pending_reup, -> { find_active.where(:orders_remaining => 0) } | |
scope :find_pending_order, ->(date) { find_active.where("subscriptions.orders_remaining > 0").where.not(:id => Order.where(:date => date).select(:subscription_id)) } | |
# ================ | |
# = Associations = | |
# ================ | |
belongs_to :user | |
belongs_to :day | |
belongs_to :address | |
has_many :orders | |
has_many :invoice_items | |
has_many :subscribed_coffees | |
has_many :great_coffees, :through => :subscribed_coffees | |
accepts_nested_attributes_for :address, :allow_destroy => true, :reject_if => proc { |obj| obj.blank? } | |
# =============== | |
# = Validations = | |
# =============== | |
validates_presence_of :user, :day, :size, :tier, :status, :orders_remaining | |
validates_presence_of :card, :on => :create, :message => "can't be blank" | |
validate do | |
errors.add(:address, "can't be blank") if address.blank? or (address_id.present? and !user.address_ids.include?(address_id)) | |
errors.add(:day, "must be active") if day and !day.active | |
qty = SIZES.with_indifferent_access[size] | |
errors.add(:base, "Please select #{qty} #{'coffee'.pluralize(qty)} for your subscription") if subscribed_coffees.length != qty | |
end | |
# ========= | |
# = Hooks = | |
# ========= | |
before_validation do | |
address.user_id ||= user_id if address.present? | |
end | |
after_create :initialize_payment! | |
after_save do | |
process_subscription_end! if orders_remaining_changed? and (orders_remaining == 0) | |
if orders_remaining_changed? and ((diff = (orders_remaining - orders_remaining_was)) > 0) | |
User.where(:id => user_id).update_all(["points = points + ?", diff]) | |
end | |
end | |
# =========== | |
# = Setters = | |
# =========== | |
def tier=(val) | |
write_attribute(:tier, val) | |
self.orders_remaining ||= TIERS[val] | |
end | |
def set_great_coffee_ids=(val) | |
self.great_coffee_ids = val | |
self.subscribed_coffees = val.map{ |i| SubscribedCoffee.new({:subscription_id => id, :great_coffee_id => i}) } | |
end | |
# ==================== | |
# = Instance Methods = | |
# ==================== | |
def apply_complimentary_order! | |
Subscription.transaction do | |
Subscription.where(:id => id, :orders_remaining => 0).update_all("orders_remaining = orders_remaining + 1") | |
user.reset_points! | |
end | |
end | |
def active? | |
status == 'active' | |
end | |
def process_subscription_end! | |
ActiveRecord::Base.transaction do | |
lock! | |
if orders_remaining == 0 | |
self.tier = 1 if on_end == 'switch-to-base' | |
self.status = 'paused' if on_end == 'pause' | |
save!(:validate => false) | |
end | |
end | |
end | |
def create_order! | |
Alerter.log("Subscription#create_order! order already exists for subscription ##{id} on #{day.next_roast}") and return false if orders.where(:date => day.next_roast).exists? | |
ActiveRecord::Base.transaction do | |
lock! | |
Alerter.log("Subscription#create_order! order ##{id} is not active.") if !active? | |
Alerter.log("Subscription#create_order! order ##{id} has #{orders_remaining} orders remaining.") if orders_remaining < 1 | |
if (orders_remaining > 0) and active? | |
orders.create!({ | |
:coffees => great_coffees.inject({}){ |memo, coffee| memo[coffee.id] = coffee.name; memo }, | |
:date => day.next_roast | |
}) | |
self.orders_remaining += -1 | |
save!(:validate => false) | |
end | |
end | |
end | |
# TODO: This needs to be retry-safe. | |
def process_switch_plan!(options = {}) | |
options = options.stringify_keys! | |
stripe_records = {} | |
Subscription.transaction do | |
lock! | |
self.tier = options.tier | |
self.size = options['size'] | |
self.set_great_coffee_ids = options.great_coffee_ids || great_coffee_ids | |
raise ActiveRecord::RecordInvalid.new(self) if !valid? | |
if tier_changed? or size_changed? | |
old_pricing_per_order = PRICING[tier_was][size_was]/TIERS[tier_was] | |
new_pricing_per_order = PRICING[tier][size]/TIERS[tier] | |
if tier_changed? | |
cost = PRICING[tier][size] | |
# refund = old_pricing_per_order * orders_remaining | |
else | |
cost = new_pricing_per_order * orders_remaining | |
# refund = old_pricing_per_order * orders_remaining | |
end | |
user.processing_invoice! do | |
customer = user.stripe_customer || user.create_stripe_customer! | |
user.invoice_items.create!({:store => Stripe::InvoiceItem.create({ | |
:amount => cost, | |
:customer => customer.id, | |
:currency => 'usd', | |
:description => (tier_changed? ? | |
"Tier #{tier} - #{size.capitalize}" : | |
"Switch from Tier #{tier_was} - #{size_was} to Tier #{tier} - #{size}"), | |
:metadata => { | |
:tier_was => tier_was, | |
:size_was => size_was, | |
:tier => tier, | |
:size => size, | |
:subscription_id => id | |
} | |
})}) | |
if orders_remaining > 0 | |
user.invoice_items.create!({:store => Stripe::InvoiceItem.create({ | |
:amount => -old_pricing_per_order, | |
:quantity => orders_remaining, | |
:customer => customer.id, | |
:currency => 'usd', | |
:description => "REFUND: Tier #{tier_was} - #{size_was.capitalize} x #{orders_remaining}", | |
:metadata => { | |
:tier_was => tier_was, | |
:size_was => size_was, | |
:tier => tier, | |
:size => size, | |
:subscription_id => id | |
} | |
})}) | |
end | |
stripe_invoice = Stripe::Invoice.create({ | |
:customer => customer.id, | |
:metadata => { | |
:tier_was => tier_was, | |
:size_was => size_was, | |
:tier => tier, | |
:size => size, | |
:subscription_id => id | |
} | |
}) | |
invoice = user.invoices.create!({:store => stripe_invoice}) | |
# => Delete any lingering orphaned invoice items attached to this invoice | |
stripe_invoice.lines.data.each do |item| | |
if !user.invoice_items.where("invoice_items.store -> 'id' = ?", item.id).exists? | |
Stripe::InvoiceItem.retrieve(item.id).delete | |
end | |
end | |
ScourOrphanedInvoicesJob.perform_now(user_id) | |
invoice.update_attribute(:store, stripe_invoice.pay) | |
# TODO: when invoice payment fails we should rollback subscription preferences and orders_remaining, but preserve invoice records. | |
self.orders_remaining = TIERS[tier] if tier_changed? | |
save!({:validate => false}) | |
end | |
end | |
end # end transaction | |
rescue ActiveRecord::RecordInvalid => e | |
return false | |
rescue Stripe::CardError => e | |
# body = e.json_body | |
# err = body[:error] | |
# | |
# puts "Status is: #{e.http_status}" | |
# puts "Type is: #{err[:type]}" | |
# puts "Code is: #{err[:code]}" | |
# # param is '' in this case | |
# puts "Param is: #{err[:param]}" | |
# puts "Message is: #{err[:message]}" | |
ScourOrphanedInvoicesJob.perform_later(user_id) | |
errors.add(:base, "There was an error processing your payment: #{e.to_s}") | |
Alerter.log(["Subscription#process_switch_plan! Payment could not be completed", e]) | |
return false | |
rescue Exception => e | |
ScourOrphanedInvoicesJob.perform_later(user_id) | |
Alerter.log(["Subscription#process_switch_plan! Unknown exception", e]) | |
raise e | |
end | |
def orders_depleted? | |
orders_remaining == 0 | |
end | |
def reup_ready? | |
orders_depleted? and active? | |
end | |
def reup! | |
if reup_ready? | |
begin | |
create_invoice! | |
rescue Stripe::CardError => e | |
ProcessReupFailedJob.perform_later(id, e.as_json) | |
end | |
else | |
Alerter.log("Subscription#reup! tried to inappropriately reup subscriptions. orders_remaining: #{orders_remaining}; status: #{status}") | |
end | |
end | |
# TODO: If InvoiceItem succeeds and then Invoice fails, we lose track of Stripe records. Needs to be built out more robust. This implementation assumes success on Stripe::Invoice.create | |
def create_invoice! | |
user.processing_invoice! do | |
customer = user.stripe_customer || user.create_stripe_customer!(card) | |
invoice_items.create!({ | |
:user => user, | |
:store => Stripe::InvoiceItem.create({ | |
:customer => customer.id, | |
:amount => price_in_cents, | |
:currency => "usd", | |
:description => "Share Coffee: Tier #{tier} - #{size.capitalize}", | |
:metadata => { | |
:tier => tier, | |
:size => size, | |
:subscription_id => id | |
} | |
}) | |
}) | |
stripe_invoice = Stripe::Invoice.create({:customer => customer.id}).pay | |
user.invoices.create!({:store => stripe_invoice}) | |
if new_record? | |
self.orders_remaining = TIERS[tier] | |
else | |
Subscription.transaction do | |
lock! | |
update_attribute(:orders_remaining, orders_remaining + TIERS[tier]) | |
end | |
end | |
end | |
end | |
def pause! | |
update_attribute(:status, 'paused') | |
end | |
def decrement_orders_remaining! | |
ActiveRecord::Base.transaction do | |
lock! | |
self.orders_remaining += -1 | |
save!(:validate => 'false') | |
end | |
end | |
def price_in_cents | |
PRICING.with_indifferent_access[tier].try(:[], size) | |
end | |
def initialize_payment! | |
create_invoice! | |
end | |
def downgrade_to_base_plan! | |
self.tier = '1' | |
save! | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1) Error: | |
SubscriptionTest#test_idempotency_of_failed_call_to_save!: | |
RuntimeError: test error foobar | |
app/models/subscription.rb:249:in `block (2 levels) in process_switch_plan!' | |
app/models/user.rb:54:in `processing_invoice!' | |
app/models/subscription.rb:186:in `block in process_switch_plan!' | |
app/models/subscription.rb:164:in `process_switch_plan!' | |
test/models/subscription_test.rb:8:in `block (4 levels) in <class:SubscriptionTest>' | |
test/models/subscription_test.rb:7:in `block (3 levels) in <class:SubscriptionTest>' | |
test/models/subscription_test.rb:6:in `block (2 levels) in <class:SubscriptionTest>' | |
test/models/subscription_test.rb:5:in `block in <class:SubscriptionTest>' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
test "idempotency of failed call to save!" do | |
@subscription = FactoryGirl.create(:subscription, {:tier => '1'}) | |
@subscription.stubs(:save!).raises(RuntimeError.new('test error foobar')) | |
assert_difference "Stripe::Invoice::STORE.length" do | |
assert_difference "Stripe::InvoiceItem::STORE.length", 2 do | |
assert_raises RuntimeError do | |
@subscription.process_switch_plan!({:tier => '2', :size => @subscription.size}) | |
end | |
Subscription.unstub(:save!) | |
@subscription.process_switch_plan!({:tier => '2', :size => @subscription.size}) | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment