module JamRuby # a sale is created every time someone tries to buy something class Sale < ActiveRecord::Base JAMTRACK_SALE = 'jamtrack' LESSON_SALE = 'lesson' POSA_SALE = 'posacard' SOURCE_RECURLY = 'recurly' SOURCE_IOS = 'ios' SOURCE_PAYPAL = 'paypal' belongs_to :retailer, class_name: 'JamRuby::Retailer' 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 #validates :retailer @@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, user, sale) # 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'] } response = IosReceiptValidator.post('/verifyReceipt', body: {'receipt-data' => receipt}.to_json, headers: {'Content-Type' => 'application/json'}) 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']] else receiptJson = SaleReceiptIOS.new receiptJson.user = user receiptJson.sale = sale receiptJson.data_blob = json_resp receiptJson.save! end price_info end def self.ios_purchase(current_user, jam_track, receipt, price_data, variant) if variant.nil? variant = ShoppingCart::JAMTRACK_STREAM end # see if we should bail because we already own these rights jam_track_right = jam_track.right_for_user(current_user) if !jam_track_right.nil? && jam_track_right.can_download # if the user already has full rights to the JamTrack, there is nothing else to do in this path return jam_track_right end if !jam_track_right.nil? && (!jam_track_right.can_download && variant == ShoppingCart::JAMTRACK_STREAM) # if the user does have the track, but isn't upgrading it, bail return jam_track_right end # 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, SOURCE_IOS) if sale.valid? 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, current_user, sale) 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: current_user.id, jam_track_id: jam_track.id}) do |jam_track_right| jam_track_right.redeemed = using_free_credit jam_track_right.version = jam_track.version end if variant == ShoppingCart::JAMTRACK_DOWNLOAD || variant == ShoppingCart::JAMTRACK_FULL jam_track_right.can_download = true jam_track_right.save 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, paypal = false) 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, paypal) 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 def self.purchase_test_drive(current_user, lesson_package_type, booking = nil, posa_card = nil) self.purchase_lesson(nil, current_user, booking, lesson_package_type, nil, nil, false, posa_card) end def self.post_sale_test_failure return true end def self.posa_activate(posa_card, retailer) sale = nil Sale.transaction(:requires_new => true) do posa_card.activate(retailer) if !posa_card.errors.any? sale = create_posa_sale(retailer, posa_card) SaleLineItem.create_from_posa_card(sale, retailer, posa_card) sale.save end end {sale: sale} end # this is easy to make generic, but right now, it just purchases lessons def self.purchase_lesson(charge, current_user, lesson_booking, lesson_package_type, lesson_session = nil, lesson_package_purchase = nil, force = false, posa_card = nil) stripe_charge = nil sale = nil purchase = nil # everything needs to go into a transaction! If anything goes wrong, we need to raise an exception to break it Sale.transaction(:requires_new => true) do sale = create_lesson_sale(current_user) if sale.valid? if lesson_booking lesson_booking.current_lesson = lesson_session lesson_booking.current_purchase = lesson_package_purchase end sale_line_item = SaleLineItem.create_from_lesson_package(current_user, sale, lesson_package_type, lesson_booking) price_info = charge_stripe_for_lesson(charge, current_user, lesson_booking, lesson_package_type, sale_line_item, lesson_session, lesson_package_purchase, force, posa_card) post_sale_test_failure if price_info[:purchase] && price_info[:purchase].errors.any? purchase = price_info[:purchase] raise ActiveRecord::Rollback end if !sale_line_item.valid? raise "invalid sale_line_item object for user #{current_user.email} and lesson_booking #{lesson_booking.id}" end # sale.source = 'stripe' 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.stripe_charge_id = price_info[:charge_id] sale.save stripe_charge = price_info[:charge] purchase = price_info[:purchase] 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. puts "invalid sale object" raise "invalid sale object" end end {sale: sale, stripe_charge: stripe_charge, purchase: purchase} end def self.charge_stripe_for_lesson(charge, current_user, lesson_booking, lesson_package_type, sale_line_item, lesson_session = nil, lesson_package_purchase = nil, force = false, posa_card = nil) if lesson_package_purchase target = lesson_package_purchase elsif lesson_session target = lesson_session else target = lesson_package_type end current_user.sync_stripe_customer purchase = lesson_package_purchase purchase = LessonPackagePurchase.create(current_user, lesson_booking, lesson_package_type, nil, nil, posa_card) if purchase.nil? if purchase.errors.any? price_info = {} price_info[:purchase] = purchase return price_info end if lesson_session lesson_session.lesson_package_purchase_id = purchase.id lesson_session.save! end subtotal_in_cents = purchase.price_in_cents tax_percent = 0 if current_user.stripe_zip_code lookup = ZipCodes.identify(current_user.stripe_zip_code) if lookup && lookup[:state_code] == 'TX' tax_percent = 0.0825 end end tax_in_cents = (subtotal_in_cents * tax_percent).round total_in_cents = subtotal_in_cents + tax_in_cents if lesson_session # not set if test drive lesson_id = lesson_session.id teacher_id = lesson_session.teacher.id teacher_name = lesson_session.teacher.name end charge_id = charge.id if charge # not set if test drive begin metadata = { lesson_package: purchase.id, lesson_session: lesson_id, teacher_id: teacher_id, teacher_name: teacher_name, charge: charge_id, user: current_user.id, tax: tax_in_cents } rescue Exception => e metadata = {metaerror: true} end stripe_charge = Stripe::Charge.create( :amount => total_in_cents, :currency => "usd", :customer => current_user.stripe_customer_id, :description => target.stripe_description(lesson_booking), :metadata => metadata ) if charge charge.stripe_charge = stripe_charge end sale_line_item.lesson_package_purchase = purchase sale_line_item.save price_info = {} price_info[:subtotal_in_cents] = subtotal_in_cents price_info[:tax_in_cents] = tax_in_cents price_info[:total_in_cents] = total_in_cents price_info[:currency] = 'USD' price_info[:charge_id] = stripe_charge.id price_info[:charge] = stripe_charge price_info[:purchase] = purchase price_info 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, is_paypal) 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 sale = nil Sale.transaction do sale = create_jam_track_sale(current_user, is_paypal ? SOURCE_PAYPAL : SOURCE_RECURLY) 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 if is_paypal sale.process_shopping_carts(current_user, shopping_carts) paypal_auth = current_user.paypal_auth @api = PayPal::SDK::Merchant::API.new @get_express_checkout_details = @api.build_get_express_checkout_details({:Token => paypal_auth.token}) @response = @api.get_express_checkout_details(@get_express_checkout_details) @@log.info("User #{current_user.email}, GetExpressCheckout: #{@response.inspect}") tax = false if @response.Ack == 'Success' payerInfo = @response.GetExpressCheckoutDetailsResponseDetails.PayerInfo if payerInfo.Address && ( payerInfo.Address.Country == 'US' && payerInfo.Address.StateOrProvince == 'TX') # we need to ask for taxes tax = true end end tax_rate = tax ? 0.0825 : 0 total = current_user.shopping_cart_total.round(2) tax_total = (total * tax_rate).round(2) total = total + tax_total total = total.round(2) @do_express_checkout_payment = @api.build_do_express_checkout_payment({ :DoExpressCheckoutPaymentRequestDetails => { :PaymentDetails => [ { :OrderTotal => { :currencyID => "USD", :value => total }, :PaymentAction => "Sale" } ], :Token => paypal_auth.token, :PayerID => paypal_auth.uid, }}) @pay_response = @api.do_express_checkout_payment(@do_express_checkout_payment) @@log.info("User #{current_user.email}, DoExpressCheckoutPayment: #{@pay_response.inspect}") # #, # @FeeAmount=#, # @TaxAmount=#, # @ExchangeRate=nil, @PaymentStatus="Completed", @PendingReason="none", @ReasonCode="none", @ProtectionEligibility="Eligible", # @ProtectionEligibilityType="ItemNotReceivedEligible,UnauthorizedPaymentEligible", @SellerDetails=#>], # @SuccessPageRedirectRequested="false", @CoupledPaymentInfo=[#]>> if @pay_response.Ack == 'Success' details = @pay_response.DoExpressCheckoutPaymentResponseDetails.PaymentInfo[0] sale.recurly_invoice_id = details.TransactionID sale.recurly_invoice_number = details.ReceiptID # now slap in all the real tax/purchase totals sale.recurly_subtotal_in_cents = ((details.GrossAmount.value.to_f - details.TaxAmount.value.to_f) * 100).to_i sale.recurly_tax_in_cents = (details.TaxAmount.value.to_f * 100).to_i sale.recurly_total_in_cents = (details.GrossAmount.value.to_f * 100).to_i sale.recurly_currency = details.GrossAmount.currencyID unless sale.save puts "Invalid sale (at end)." raise PayPalClientError, "Invalid sale (at end)." end else @@log.error("User #{current_user.email}, DoExpressCheckoutPayment: #{@pay_response.inspect}") raise PayPalClientError, @pay_response.Errors[0].LongMessage end else client = RecurlyClient.new 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 puts "Invalid sale (at end)." 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 rescue => e puts "UNKNOWN E #{e}" end else raise RecurlyClientError, "Could not find account to place order." end end end else raise RecurlyClientError, "Invalid sale." end end sale end def process_shopping_carts(current_user, shopping_carts, account = nil) 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, recurly_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, shopping_cart.variant) # 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 recurly_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 = recurly_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: current_user.id, jam_track_id: jam_track.id}) do |jam_track_right| jam_track_right.redeemed = shopping_cart.free? jam_track_right.version = jam_track.version end # deal with variant behavior if shopping_cart.purchasing_downloadable_rights? jam_track_right.can_download = true jam_track_right.save 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_now 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 is_lesson_sale? sale_type == LESSON_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 def self.create_lesson_sale(user) sale = Sale.new sale.user = user sale.sale_type = LESSON_SALE # gift cards and jam tracks are sold with this type of sale sale.order_total = 0 sale.save sale end def self.create_posa_sale(retailer, posa_card) sale = Sale.new sale.retailer = retailer sale.sale_type = POSA_SALE # gift cards and jam tracks are sold with this type of sale sale.order_total = posa_card.product_info[:price] 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