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