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

514 lines
19 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
# The expectation is that this code would throw an exception (breaking the transaction that encompasses it),
# if it can't validate the receipt, or communicate with Apple at all, etc
#
# So, if this raises exceptions, you can handle them in the stubbed out begin/rescue in ApiJamTracksController#ios_order_placed
def self.validateIOSReceipt(receipt, price_data)
byebug
# these are all 'in cents' (as painfully named to be very clear), and all expected to be integers
price = price_data['product_price'].to_f * 100.0
price_info = {
subtotal_in_cents: price,
total_in_cents: price,
tax_in_cents: nil,
currency: price_data['product_currency']
}
# communicate with Apple; populate price_info
url = 'production' != Rails.env ? "https://sandbox.itunes.apple.com/verifyReceipt" : "https://buy.itunes.apple.com/verifyReceipt"
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
request = Net::HTTP::Post.new("/v1.1/auth")
request.add_field('Content-Type', 'application/json')
request.body = { 'receipt-data' => receipt }
begin
response = http.request(request)
rescue
raise $!
end
json_resp = JSON.parse(response.body)
# https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html#//apple_ref/doc/uid/TP40010573-CH106-SW1
if 0 != json_resp.status
err_msgs = {
21000 => 'The App Store could not read the JSON object you provided.',
21002 => 'The data in the receipt-data property was malformed or missing.',
21003 => 'The receipt could not be authenticated.',
21005 => 'The receipt server is not currently available.',
21007 => 'This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead.',
21008 => 'This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead.'
}
raise err_msgs[json_resp.status]
end
price_info
end
def self.ios_purchase(current_user, jam_track, receipt, price_data)
jam_track_right = nil
# everything needs to go into a transaction! If anything goes wrong, we need to raise an exception to break it
Sale.transaction do
using_free_credit = current_user.redeem_free_credit
sale = create_jam_track_sale(current_user, 'ios')
if sale.valid?
# JONATHAN: the 'redeem freebie' path has no communication to Apple. That's OK I assume?
if using_free_credit
SaleLineItem.create_from_jam_track(current_user, sale, jam_track, using_free_credit)
sale.recurly_subtotal_in_cents = 0
sale.recurly_tax_in_cents = 0
sale.recurly_total_in_cents = 0
sale.recurly_currency = 'USD'
sale.save!
else
price_info = validateIOSReceipt(receipt, price_data)
SaleLineItem.create_from_jam_track(current_user, sale, jam_track, using_free_credit)
sale.recurly_subtotal_in_cents = price_info[:subtotal_in_cents]
sale.recurly_tax_in_cents = price_info[:tax_in_cents]
sale.recurly_total_in_cents = price_info[:total_in_cents]
sale.recurly_currency = price_info[:currency]
sale.save!
end
else
# should not get out of testing. This would be very rare (i.e., from a big regression). Sale is always valid at this point.
raise "invalid sale object"
end
# if we make it this far, all is well!
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 = using_free_credit
jam_track_right.version = jam_track.version
end
end
jam_track_right
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 = []
if Sale.is_mixed(shopping_carts)
# the controller checks this too; this is just an extra-level of sanity checking
return sales
end
jam_track_sale = order_jam_tracks(current_user, shopping_carts)
sales << jam_track_sale if jam_track_sale
# TODO: process shopping_carts_subscriptions
sales
end
def self.is_only_freebie(shopping_carts)
free = true
shopping_carts.each do |cart|
free = cart.product_info[:free]
if !free
break
end
end
free
end
# we don't allow mixed shopping carts :/
def self.is_mixed(shopping_carts)
free = false
non_free = false
shopping_carts.each do |cart|
if cart.product_info[:free]
free = true
else
non_free = true
end
end
free && non_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)
shopping_carts_jam_tracks = []
shopping_carts_subscriptions = []
shopping_carts_gift_cards = []
shopping_carts.each do |shopping_cart|
if shopping_cart.is_jam_track?
shopping_carts_jam_tracks << shopping_cart
elsif shopping_cart.is_gift_card?
shopping_carts_gift_cards << shopping_cart
else
# XXX: this may have to be revisited when we actually have something other than JamTracks for puchase
raise "unknown shopping cart type #{shopping_cart.cart_type}"
shopping_carts_subscriptions << shopping_cart
end
end
client = RecurlyClient.new
sale = nil
Sale.transaction do
sale = create_jam_track_sale(current_user)
if sale.valid?
if is_only_freebie(shopping_carts)
sale.process_shopping_carts(current_user, shopping_carts, 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.sale_line_items.each do |sale_line_item|
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
end
sale.save
else
account = client.get_account(current_user)
if account.present?
purge_pending_adjustments(account)
created_adjustments = sale.process_shopping_carts(current_user, shopping_carts, 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_shopping_carts(current_user, shopping_carts, account)
created_adjustments = []
begin
shopping_carts.each do |shopping_cart|
process_shopping_cart(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_shopping_cart(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
cart_product = shopping_cart.cart_product
if shopping_cart.is_jam_track?
jam_track = 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
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
if shopping_cart.is_jam_track?
jam_track = cart_product
# 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?
jam_track_right.version = jam_track.version
end
# also if the purchase was a free one, then:
# first, mark the free has_redeemable_jamtrack field if that's still true
# and if still they have more free things, then redeem the giftable_jamtracks
if shopping_cart.free?
current_user.redeem_free_credit
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
# blow up the transaction if the JamTrackRight did not get created
raise RecurlyClientError.new(jam_track_right.errors) if jam_track_right.errors.any?
elsif shopping_cart.is_gift_card?
gift_card_type = cart_product
raise "gift card is null" if gift_card_type.nil?
raise if current_user.nil?
shopping_cart.quantity.times do |item|
gift_card_purchase = GiftCardPurchase.new(
{
user: current_user,
gift_card_type: gift_card_type
})
unless gift_card_purchase.save
raise RecurlyClientError.new(gift_card_purchase.errors)
end
end
else
raise 'unknown shopping cart type: ' + shopping_cart.cart_type
end
# delete the shopping cart; it's been dealt with
shopping_cart.destroy if shopping_cart
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_source=nil)
sale = Sale.new
sale.user = user
sale.sale_type = JAMTRACK_SALE # gift cards and jam tracks are sold with this type of sale
sale.order_total = 0
sale.source = sale_source if sale_source
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