jam-cloud/ruby/lib/jam_ruby/models/sale.rb

380 lines
14 KiB
Ruby

module JamRuby
# a sale is created every time someone tries to buy something
class Sale < ActiveRecord::Base
JAMTRACK_SALE = 'jamtrack'
belongs_to :user, class_name: 'JamRuby::User'
has_many :sale_line_items, class_name: 'JamRuby::SaleLineItem'
has_many :recurly_transactions, class_name: 'JamRuby::RecurlyTransactionWebHook', inverse_of: :sale, foreign_key: 'invoice_id', primary_key: 'recurly_invoice_id'
validates :order_total, numericality: {only_integer: false}
validates :user, presence: true
@@log = Logging.logger[Sale]
def self.index(user, params = {})
limit = params[:per_page]
limit ||= 20
limit = limit.to_i
query = Sale.limit(limit)
.includes([:recurly_transactions, :sale_line_items])
.where('sales.user_id' => user.id)
.order('sales.created_at DESC')
current_page = params[:page].nil? ? 1 : params[:page].to_i
next_page = current_page + 1
# will_paginate gem
query = query.paginate(:page => current_page, :per_page => limit)
if query.length == 0 # no more results
{ query: query, next_page: nil}
elsif query.length < limit # no more results
{ query: query, next_page: nil}
else
{ query: query, next_page: next_page }
end
end
def state
original_total = self.recurly_total_in_cents
is_voided = false
refund_total = 0
recurly_transactions.each do |transaction|
if transaction.is_voided?
is_voided = true
else
end
if transaction.is_refund?
refund_total = refund_total + transaction.amount_in_cents
end
end
# if refund_total is > 0, then you have a refund.
# if voided is true, then in theory the whole thing has been refunded
{
voided: is_voided,
original_total: original_total,
refund_total: refund_total
}
end
def self.preview_invoice(current_user, shopping_carts)
line_items = {jam_tracks: []}
shopping_carts_jam_tracks = []
shopping_carts_subscriptions = []
shopping_carts.each do |shopping_cart|
if shopping_cart.is_jam_track?
shopping_carts_jam_tracks << shopping_cart
else
# XXX: this may have to be revisited when we actually have something other than JamTracks for puchase
shopping_carts_subscriptions << shopping_cart
end
end
jam_track_items = preview_invoice_jam_tracks(current_user, shopping_carts_jam_tracks)
line_items[:jam_tracks] = jam_track_items if jam_track_items
# TODO: process shopping_carts_subscriptions
line_items
end
# place_order will create one or more sales based on the contents of shopping_carts for the current user
# individual subscriptions will end up create their own sale (you can't have N subscriptions in one sale--recurly limitation)
# jamtracks however can be piled onto the same sale as adjustments (VRFS-3028)
# so this method may create 1 or more sales, , where 2 or more sales can occur if there are more than one subscriptions or subscription + jamtrack
def self.place_order(current_user, shopping_carts)
sales = []
shopping_carts_jam_tracks = []
shopping_carts_subscriptions = []
shopping_carts.each do |shopping_cart|
if shopping_cart.is_jam_track?
shopping_carts_jam_tracks << shopping_cart
else
# XXX: this may have to be revisited when we actually have something other than JamTracks for puchase
shopping_carts_subscriptions << shopping_cart
end
end
jam_track_sale = order_jam_tracks(current_user, shopping_carts_jam_tracks)
sales << jam_track_sale if jam_track_sale
# TODO: process shopping_carts_subscriptions
sales
end
def self.preview_invoice_jam_tracks(current_user, shopping_carts_jam_tracks)
### XXX TODO;
# we currently use a fake plan in Recurly to estimate taxes using the Pricing.Attach metod in Recurly.js
# if we were to implement this the right way (ensure adjustments are on the account as necessary), then it would be better (more correct)
# just a pain to implement
end
def self.is_only_freebie(shopping_carts_jam_tracks)
shopping_carts_jam_tracks.length == 1 && shopping_carts_jam_tracks[0].product_info[:free]
end
# this method will either return a valid sale, or throw a RecurlyClientError or ActiveRecord validation error (save! failed)
# it may return an nil sale if the JamTrack(s) specified by the shopping carts are already owned
def self.order_jam_tracks(current_user, shopping_carts_jam_tracks)
client = RecurlyClient.new
sale = nil
Sale.transaction do
sale = create_jam_track_sale(current_user)
if sale.valid?
if is_only_freebie(shopping_carts_jam_tracks)
sale.process_jam_tracks(current_user, shopping_carts_jam_tracks, nil)
sale.recurly_subtotal_in_cents = 0
sale.recurly_tax_in_cents = 0
sale.recurly_total_in_cents = 0
sale.recurly_currency = 'USD'
if sale.sale_line_items.count == 0
@@log.info("no sale line items associated with sale")
# we must have ditched some of the sale items. let's just abort this sale
sale.destroy
sale = nil
return sale
end
sale_line_item = sale.sale_line_items[0]
sale_line_item.recurly_tax_in_cents = 0
sale_line_item.recurly_total_in_cents = 0
sale_line_item.recurly_currency = 'USD'
sale_line_item.recurly_discount_in_cents = 0
sale.save
else
account = client.get_account(current_user)
if account.present?
purge_pending_adjustments(account)
created_adjustments = sale.process_jam_tracks(current_user, shopping_carts_jam_tracks, account)
# now invoice the sale ... almost done
begin
invoice = account.invoice!
sale.recurly_invoice_id = invoice.uuid
sale.recurly_invoice_number = invoice.invoice_number
# now slap in all the real tax/purchase totals
sale.recurly_subtotal_in_cents = invoice.subtotal_in_cents
sale.recurly_tax_in_cents = invoice.tax_in_cents
sale.recurly_total_in_cents = invoice.total_in_cents
sale.recurly_currency = invoice.currency
# and resolve against sale_line_items
sale.sale_line_items.each do |sale_line_item|
found_line_item = false
invoice.line_items.each do |line_item|
if line_item.uuid == sale_line_item.recurly_adjustment_uuid
sale_line_item.recurly_tax_in_cents = line_item.tax_in_cents
sale_line_item.recurly_total_in_cents =line_item.total_in_cents
sale_line_item.recurly_currency = line_item.currency
sale_line_item.recurly_discount_in_cents = line_item.discount_in_cents
found_line_item = true
break
end
end
if !found_line_item
@@log.error("can't find line item #{sale_line_item.recurly_adjustment_uuid}")
puts "CANT FIND LINE ITEM"
end
end
unless sale.save
raise RecurlyClientError, "Invalid sale (at end)."
end
rescue Recurly::Resource::Invalid => e
# this exception is thrown by invoice! if the invoice is invalid
sale.rollback_adjustments(current_user, created_adjustments)
sale = nil
raise ActiveRecord::Rollback # kill all db activity, but don't break outside logic
end
else
raise RecurlyClientError, "Could not find account to place order."
end
end
else
raise RecurlyClientError, "Invalid sale."
end
end
sale
end
def process_jam_tracks(current_user, shopping_carts_jam_tracks, account)
created_adjustments = []
begin
shopping_carts_jam_tracks.each do |shopping_cart|
process_jam_track(current_user, shopping_cart, account, created_adjustments)
end
rescue Recurly::Error, NoMethodError => x
# rollback any adjustments created if error
rollback_adjustments(user, created_adjustments)
raise RecurlyClientError, x.to_s
rescue Exception => e
# rollback any adjustments created if error
rollback_adjustments(user, created_adjustments)
raise e
end
created_adjustments
end
def process_jam_track(current_user, shopping_cart, account, created_adjustments)
recurly_adjustment_uuid = nil
recurly_adjustment_credit_uuid = nil
# we do this because of ShoppingCart.remove_jam_track_from_cart; if it occurs, which should be rare, we need fresh shopping cart info
shopping_cart.reload
# get the JamTrack in this shopping cart
jam_track = shopping_cart.cart_product
if jam_track.right_for_user(current_user)
# if the user already owns the JamTrack, we should just skip this cart item, and destroy it
# if this occurs, we have to reload every shopping_cart as we iterate. so, we do at the top of the loop
ShoppingCart.remove_jam_track_from_cart(current_user, shopping_cart)
return
end
if account
# ask the shopping cart to create the correct Recurly adjustment attributes for a JamTrack
adjustments = shopping_cart.create_adjustment_attributes(current_user)
adjustments.each do |adjustment|
# create the adjustment at Recurly (this may not look like it, but it is a REST API)
created_adjustment = account.adjustments.new(adjustment)
created_adjustment.save
# if the adjustment could not be made, bail
raise RecurlyClientError.new(created_adjustment.errors) if created_adjustment.errors.any?
# keep track of adjustments we created for this order, in case we have to roll them back
created_adjustments << created_adjustment
if ShoppingCart.is_product_purchase?(adjustment)
# this was a normal product adjustment, so track it as such
recurly_adjustment_uuid = created_adjustment.uuid
else
# this was a 'credit' adjustment, so track it as such
recurly_adjustment_credit_uuid = created_adjustment.uuid
end
end
end
# create one sale line item for every jam track
sale_line_item = SaleLineItem.create_from_shopping_cart(self, shopping_cart, nil, recurly_adjustment_uuid, recurly_adjustment_credit_uuid)
# if the sale line item is invalid, blow up the transaction
unless sale_line_item.valid?
@log.error("sale item invalid! #{sale_line_item.errors.inspect}")
puts("sale item invalid! #{sale_line_item.errors.inspect}")
Stats.write('web.recurly.purchase.sale_invalid', {message: sale_line_item.errors.to_s, value: 1})
raise RecurlyClientError.new(sale_line_item.errors)
end
# create a JamTrackRight (this needs to be in a transaction too to make sure we don't make these by accident)
jam_track_right = JamRuby::JamTrackRight.find_or_create_by_user_id_and_jam_track_id(current_user.id, jam_track.id) do |jam_track_right|
jam_track_right.redeemed = shopping_cart.free?
end
# also if the purchase was a free one, then update the user record to no longer allow redeemed jamtracks
if shopping_cart.free?
User.where(id: current_user.id).update_all(has_redeemable_jamtrack: false)
current_user.has_redeemable_jamtrack = false # make sure model reflects the truth
end
# this can't go in the block above, as it's here to fix bad subscription UUIDs in an update path
if jam_track_right.recurly_adjustment_uuid != recurly_adjustment_uuid
jam_track_right.recurly_adjustment_uuid = recurly_adjustment_uuid
jam_track_right.recurly_adjustment_credit_uuid = recurly_adjustment_credit_uuid
unless jam_track_right.save
raise RecurlyClientError.new(jam_track_right.errors)
end
end
# delete the shopping cart; it's been dealt with
shopping_cart.destroy if shopping_cart
# blow up the transaction if the JamTrackRight did not get created
raise RecurlyClientError.new(jam_track_right.errors) if jam_track_right.errors.any?
end
def rollback_adjustments(current_user, adjustments)
begin
adjustments.each { |adjustment| adjustment.destroy }
rescue Exception => e
AdminMailer.alerts({
subject: "ACTION REQUIRED: #{current_user.email} did not have all of his adjustments destroyed in rollback",
body: "go delete any adjustments on the account that don't belong. error: #{e}\n\nAdjustments: #{adjustments.inspect}"
}).deliver
end
end
def self.purge_pending_adjustments(account)
account.adjustments.pending.find_each do |adjustment|
# we only pre-emptively destroy pending adjustments if they appear to be created by the server
adjustment.destroy if ShoppingCart.is_server_pending_adjustment?(adjustment)
end
end
def is_jam_track_sale?
sale_type == JAMTRACK_SALE
end
def self.create_jam_track_sale(user)
sale = Sale.new
sale.user = user
sale.sale_type = JAMTRACK_SALE
sale.order_total = 0
sale.save
sale
end
# this checks just jamtrack sales appropriately
def self.check_integrity_of_jam_track_sales
Sale.select([:total, :voided]).find_by_sql(
"SELECT COUNT(sales.id) AS total,
COUNT(CASE WHEN transactions.transaction_type = '#{RecurlyTransactionWebHook::VOID}' THEN 1 ELSE null END) voided
FROM sales
LEFT OUTER JOIN recurly_transaction_web_hooks as transactions ON invoice_id = sales.recurly_invoice_id
WHERE sale_type = '#{JAMTRACK_SALE}'")
end
end
end