Skip to content

Instantly share code, notes, and snippets.

@gabeodess
Last active August 29, 2015 14:16
Show Gist options
  • Save gabeodess/23cdb95c4e20b9426c15 to your computer and use it in GitHub Desktop.
Save gabeodess/23cdb95c4e20b9426c15 to your computer and use it in GitHub Desktop.
Stubbing #save! and raising an exception with Mocha
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
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>'
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