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