diff --git a/db/manifest b/db/manifest index 0a8041934..35c7eb7ba 100755 --- a/db/manifest +++ b/db/manifest @@ -369,4 +369,5 @@ second_ed.sql second_ed_v2.sql retailers_v2.sql retailer_interest.sql -connection_role.sql \ No newline at end of file +connection_role.sql +retailer_payment_split.sql \ No newline at end of file diff --git a/db/up/retailer_payment_split.sql b/db/up/retailer_payment_split.sql new file mode 100644 index 000000000..336beee4d --- /dev/null +++ b/db/up/retailer_payment_split.sql @@ -0,0 +1,2 @@ +ALTER TABLE retailers ADD COLUMN payment VARCHAR; +ALTER TABLE lesson_bookings ADD COLUMN payment VARCHAR; \ No newline at end of file diff --git a/ruby/lib/jam_ruby.rb b/ruby/lib/jam_ruby.rb index 4afe09eb5..cd051e8bb 100755 --- a/ruby/lib/jam_ruby.rb +++ b/ruby/lib/jam_ruby.rb @@ -40,6 +40,7 @@ require "jam_ruby/errors/state_error" require "jam_ruby/errors/jam_argument_error" require "jam_ruby/errors/jam_record_not_found" require "jam_ruby/errors/conflict_error" +require "jam_ruby/errors/pay_pal_client_error" require "jam_ruby/lib/app_config" require "jam_ruby/lib/s3_manager_mixin" require "jam_ruby/lib/s3_public_manager_mixin" diff --git a/ruby/lib/jam_ruby/errors/pay_pal_client_error.rb b/ruby/lib/jam_ruby/errors/pay_pal_client_error.rb new file mode 100644 index 000000000..faf3159f4 --- /dev/null +++ b/ruby/lib/jam_ruby/errors/pay_pal_client_error.rb @@ -0,0 +1,19 @@ +module JamRuby + class PayPalClientError < StandardError + + attr_accessor :errors + def initialize(data) + if data.respond_to?('has_key?') + self.errors = data + else + self.errors = {:message=>data.to_s} + end + end # initialize + + def to_s + s=super + s << ", errors: #{errors.inspect}" if self.errors.any? + s + end + end +end \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/lesson_booking.rb b/ruby/lib/jam_ruby/models/lesson_booking.rb index 8a2688f06..0d4007550 100644 --- a/ruby/lib/jam_ruby/models/lesson_booking.rb +++ b/ruby/lib/jam_ruby/models/lesson_booking.rb @@ -531,7 +531,7 @@ module JamRuby distribution = teacher_distribution_price_in_cents(target) if education - (distribution * 0.0625).round + (distribution * 0.0625).round # 0.0625 is 1/4th of 25% else distribution end diff --git a/ruby/lib/jam_ruby/models/lesson_session.rb b/ruby/lib/jam_ruby/models/lesson_session.rb index f0b3bbb2f..f21bceee7 100644 --- a/ruby/lib/jam_ruby/models/lesson_session.rb +++ b/ruby/lib/jam_ruby/models/lesson_session.rb @@ -216,11 +216,20 @@ module JamRuby self.status = STATUS_COMPLETED + # RETAILERPAY2 + + if success && lesson_booking.requires_teacher_distribution?(self) + is_education_school_on_school = lesson_booking.school_on_school_payment? + self.teacher_distributions << TeacherDistribution.create_for_lesson(self, false) - if lesson_booking.school_on_school_payment? + if is_education_school_on_school self.teacher_distributions << TeacherDistribution.create_for_lesson(self, true) end + + # this is a bit of a hack, in how the code is structured. + # but basically, the distributions calculated are too dynamic for the above code. + # if this is a retailer end if self.save diff --git a/ruby/lib/jam_ruby/models/sale.rb b/ruby/lib/jam_ruby/models/sale.rb index 5f6ff9c05..96740502b 100644 --- a/ruby/lib/jam_ruby/models/sale.rb +++ b/ruby/lib/jam_ruby/models/sale.rb @@ -9,6 +9,7 @@ module JamRuby SOURCE_RECURLY = 'recurly' SOURCE_IOS = 'ios' + SOURCE_PAYPAL = 'paypal' belongs_to :retailer, class_name: 'JamRuby::Retailer' belongs_to :user, class_name: 'JamRuby::User' @@ -166,7 +167,7 @@ module JamRuby # 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) + def self.place_order(current_user, shopping_carts, paypal = false) sales = [] @@ -176,7 +177,7 @@ module JamRuby # return sales #end - jam_track_sale = order_jam_tracks(current_user, shopping_carts) + jam_track_sale = order_jam_tracks(current_user, shopping_carts, paypal) sales << jam_track_sale if jam_track_sale # TODO: process shopping_carts_subscriptions @@ -377,7 +378,7 @@ module JamRuby # 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) + def self.order_jam_tracks(current_user, shopping_carts, is_paypal) shopping_carts_jam_tracks = [] shopping_carts_subscriptions = [] @@ -395,11 +396,9 @@ module JamRuby end end - client = RecurlyClient.new - sale = nil Sale.transaction do - sale = create_jam_track_sale(current_user, SOURCE_RECURLY) + sale = create_jam_track_sale(current_user, is_paypal ? SOURCE_PAYPAL : SOURCE_RECURLY) if sale.valid? if is_only_freebie(shopping_carts) @@ -429,61 +428,142 @@ module JamRuby else - account = client.get_account(current_user) - if account.present? + if is_paypal - purge_pending_adjustments(account) + sale.process_shopping_carts(current_user, shopping_carts) - created_adjustments = sale.process_shopping_carts(current_user, shopping_carts, account) + paypal_auth = current_user.paypal_auth - # now invoice the sale ... almost done + @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) - begin - invoice = account.invoice! - sale.recurly_invoice_id = invoice.uuid - sale.recurly_invoice_number = invoice.invoice_number + @@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 = 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 + 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 "WTF" - raise RecurlyClientError, "Invalid sale (at end)." + puts "Invalid sale (at end)." + raise PayPalClientError, "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}" + else + @@log.error("User #{current_user.email}, DoExpressCheckoutPayment: #{@pay_response.inspect}") + raise PayPalClientError, @pay_response.Errors[0].LongMessage end else - raise RecurlyClientError, "Could not find account to place order." + 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 @@ -493,7 +573,7 @@ module JamRuby sale end - def process_shopping_carts(current_user, shopping_carts, account) + def process_shopping_carts(current_user, shopping_carts, account = nil) created_adjustments = [] @@ -515,7 +595,7 @@ module JamRuby end - def process_shopping_cart(current_user, shopping_cart, account, created_adjustments) + def process_shopping_cart(current_user, shopping_cart, recurly_account, created_adjustments) recurly_adjustment_uuid = nil recurly_adjustment_credit_uuid = nil @@ -536,14 +616,14 @@ module JamRuby end - if account + 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 = account.adjustments.new(adjustment) + created_adjustment = recurly_account.adjustments.new(adjustment) created_adjustment.save # if the adjustment could not be made, bail diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index 894275058..65f231dfb 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -1912,6 +1912,13 @@ module JamRuby stats end + def shopping_cart_total + total = 0 + shopping_carts.each do |shopping_cart| + total += shopping_cart.product_info[:total_price] + end + total + end def destroy_all_shopping_carts ShoppingCart.where("user_id=?", self).destroy_all end @@ -2053,6 +2060,15 @@ module JamRuby user_authorizations.where(provider: "stripe_connect").first end + def paypal_auth + user_authorizations.where(provider: 'paypal').first + end + + def has_paypal_auth? + auth = paypal_auth + auth && (!auth.token_expiration || auth.token_expiration > Time.now) + end + def has_stripe_connect? auth = stripe_auth auth && (!auth.token_expiration || auth.token_expiration > Time.now) diff --git a/web/Gemfile b/web/Gemfile index ddc1cf308..b5471a94f 100644 --- a/web/Gemfile +++ b/web/Gemfile @@ -28,6 +28,8 @@ gem 'sprockets-rails', '2.3.2' gem 'non-stupid-digest-assets' #gem 'license_finder' gem 'pg_migrate', '0.1.14' +#gem 'paypal-sdk-rest' +gem 'paypal-sdk-merchant', github: 'sylv3rblade/merchant-sdk-ruby' gem 'kickbox' gem 'oj', '2.10.2' gem 'builder' diff --git a/web/app/assets/javascripts/application.js b/web/app/assets/javascripts/application.js index 778e6ce47..49c563dc0 100644 --- a/web/app/assets/javascripts/application.js +++ b/web/app/assets/javascripts/application.js @@ -27,7 +27,7 @@ //= require jquery.Jcrop //= require jquery.naturalsize //= require jquery.queryparams -//= require jquery.clipboard +//= require clipboard //= require jquery.timeago //= require jquery.easydropdown //= require jquery.scrollTo diff --git a/web/app/assets/javascripts/dialog/shareDialog.js b/web/app/assets/javascripts/dialog/shareDialog.js index 44d031682..b6aac6ae0 100644 --- a/web/app/assets/javascripts/dialog/shareDialog.js +++ b/web/app/assets/javascripts/dialog/shareDialog.js @@ -11,6 +11,7 @@ var userDetail = null; var entity = null; var remainingCap = 140 - 22 - 1; // 140 tweet max, minus 22 for link size, minus 1 for space + var clipboard = null; function showSpinner() { $(dialogId + ' .dialog-inner').hide(); @@ -444,27 +445,6 @@ function afterShow() { $("#shareType").text(entityType); - if(context.JK.hasFlash()) { - $("#btn-share-copy").clipboard({ - path: '/assets/jquery.clipboard.swf', - copy: function() { - // Return text in closest element (useful when you have multiple boxes that can be copied) - return $(".link-contents").text(); - } - }); - } - else { - if(context.jamClient) { - // uses bridge call to ultimately access QClipboard - $("#btn-share-copy").unbind('click').click(function() { - context.jamClient.SaveToClipboard($(".link-contents").text()); - return false; - }) - } - else { - logger.debug("no copy-to-clipboard capabilities") - } - } } function afterHide() { @@ -486,6 +466,20 @@ //initDialog(); facebookHelper.deferredLoginStatus().done(function(response) { handleFbStateChange(response); }); + + if(context.jamClient.IsNativeClient()) { + $("#btn-share-copy").unbind('click').click(function() { + context.jamClient.SaveToClipboard($("#link-contents").text()); + return false; + }) + } + else { + clipboard = new Clipboard('#btn-share-copy', { + text: function(trigger) { + return $("#link-contents").text(); + } + }) + } } this.initialize = initialize; diff --git a/web/app/assets/javascripts/jam_rest.js b/web/app/assets/javascripts/jam_rest.js index 4e74551d1..a206635b9 100644 --- a/web/app/assets/javascripts/jam_rest.js +++ b/web/app/assets/javascripts/jam_rest.js @@ -2734,6 +2734,27 @@ }) } + function paypalDetail(options) { + options = options || {} + return $.ajax({ + type: 'POST', + url: '/api/paypal/checkout/detail', + dataType: 'json', + contentType: 'application/json', + data: JSON.stringify(options) + }) + } + + function paypalPlaceOrder(options) { + options = options || {} + return $.ajax({ + type: 'POST', + url: '/api/paypal/checkout/confirm', + dataType: 'json', + contentType: 'application/json', + data: JSON.stringify(options) + }) + } function initialize() { return self; @@ -2977,6 +2998,8 @@ this.posaActivate = posaActivate; this.posaClaim = posaClaim; this.sendRetailerCustomerEmail = sendRetailerCustomerEmail; + this.paypalDetail = paypalDetail; + this.paypalPlaceOrder = paypalPlaceOrder; return this; }; })(window,jQuery); diff --git a/web/app/assets/javascripts/landing/landing.js b/web/app/assets/javascripts/landing/landing.js index 58d0fa6f2..0410ed9c3 100644 --- a/web/app/assets/javascripts/landing/landing.js +++ b/web/app/assets/javascripts/landing/landing.js @@ -9,7 +9,7 @@ //= require jquery.queryparams //= require jquery.hoverIntent //= require jquery.cookie -//= require jquery.clipboard +//= require clipboard //= require jquery.easydropdown //= require jquery.carousel-1.1 //= require jquery.mousewheel-3.1.9 diff --git a/web/app/assets/javascripts/react-components/JamTrackFilterScreen.js.jsx.coffee b/web/app/assets/javascripts/react-components/JamTrackFilterScreen.js.jsx.coffee index 15c26b9d7..b5500904e 100644 --- a/web/app/assets/javascripts/react-components/JamTrackFilterScreen.js.jsx.coffee +++ b/web/app/assets/javascripts/react-components/JamTrackFilterScreen.js.jsx.coffee @@ -213,7 +213,7 @@ MIX_MODES = context.JK.MIX_MODES .done((response) => @setState({jamtracks: response.jamtracks, next: response.next, searching: false, first_search: false, currentPage: 1, count: response.count}) ) - .fail(() => + .fail((jqXHR) => @app.notifyServerError jqXHR, 'Search Unavailable' @setState({searching: false, first_search: false}) ) diff --git a/web/app/assets/javascripts/react-components/JamTrackSearchScreen.js.jsx.coffee b/web/app/assets/javascripts/react-components/JamTrackSearchScreen.js.jsx.coffee index 56adbf95c..b7f62b7f8 100644 --- a/web/app/assets/javascripts/react-components/JamTrackSearchScreen.js.jsx.coffee +++ b/web/app/assets/javascripts/react-components/JamTrackSearchScreen.js.jsx.coffee @@ -326,12 +326,12 @@ MIX_MODES = context.JK.MIX_MODES .done((response) => @setState({jamtracks: response.jamtracks, next: response.next, searching: false, first_search: false, currentPage: 1, count: response.count}) ) - .fail(() => + .fail((jqXHR) => @app.notifyServerError jqXHR, 'Search Unavailable' @setState({searching: false, first_search: false}) ) ) - .fail(() => + .fail((jqXHR) => @app.notifyServerError jqXHR, 'Search Unavailable' @setState({searching: false, first_search: false}) ) diff --git a/web/app/assets/javascripts/react-components/PayPalConfirmationScreen.js.jsx.coffee b/web/app/assets/javascripts/react-components/PayPalConfirmationScreen.js.jsx.coffee new file mode 100644 index 000000000..771716b92 --- /dev/null +++ b/web/app/assets/javascripts/react-components/PayPalConfirmationScreen.js.jsx.coffee @@ -0,0 +1,160 @@ +context = window +MIX_MODES = context.JK.MIX_MODES + +@PayPalConfirmationScreen = React.createClass({ + + mixins: [Reflux.listenTo(@AppStore, "onAppInit"), Reflux.listenTo(@UserStore, "onUserChanged")] + + render: () -> + content = null + + + if this.state.sold + + if context.jamClient && context.jamClient.IsNativeClient() + platformMessage = `
+

To play your purchased JamTrack, start a session and then open the JamTrack

+ +
+ Click Here to Start a Session +
+
+
` + else + platformMessage = + `` + content = `
+

Thank you for your order!

+ {platformMessage} +
` + else + orderButtons = {"button-orange": true, "place-order-btn": true, disabled: this.state.ordering } + cancelButtons = {"button-grey": true, "cancel": true, disabled: this.state.ordering } + + content = `
+

Confirm PayPal Payment

+ +

You have not yet made a payment via PayPal. Please review your purchase and confirm or cancel.

+ +
+ CONFIRM PURCHASE WITH + PAYPAL + CANCEL + +
+
+ + + ` + + `
+
+ {content} +
+
` + + placeOrder: (e) -> + e.preventDefault() + if this.state.ordering + return + @setState({ordering: true}) + + console.log("placing order with paypal") + @rest.paypalPlaceOrder() + .done((response) => + console.log("paypal detail obtained", response) + + @setState({sold: true, ordering: false}) + context.JK.JamTrackUtils.checkShoppingCart() + @app.refreshUser() + ) + .fail((jqXHR) => + @setState({ordering: false}) + if jqXHR.status == 404 + context.JK.Banner.showAlert('PayPal Session Over', 'Your PayPal authorization has expired. Please restart the PayPal confirmation process. Click Here to Checkout Again.') + else if jqXHR.status == 422 + response = JSON.parse(jqXHR.responseText) + context.JK.Banner.showAlert('PayPal Purchase Error', 'PayPal: ' + response.message) + else + context.JK.Banner.showAlert('PayPal/Sales Error', 'Please contact support@jamkazam.com') + ) + + cancelOrder: (e) -> + e.preventDefault() + + window.location = '/client#/jamtrack' + + getInitialState: () -> + {} + + componentDidMount: () -> + + componentDidUpdate: () -> + + afterShow: (data) -> + rest.getShoppingCarts() + .done((carts) => + @setState({carts: carts}) + if carts.length == 0 + window.location = '/client#/jamtrack' + return + + @rest.paypalDetail() + .done((response) => + console.log("paypal detail obtained", response) + ) + .fail((jqXHR) => + if jqXHR.status == 404 + context.JK.Banner.showAlert('PayPal Session Over', 'Your PayPal authorization has expired. Please restart the PayPal confirmation process. Click Here to Checkout Again.') + else if jqXHR.status == 422 + response = JSON.parse(jqXHR.responseText) + context.JK.Banner.showAlert('PayPal Purchase Error', 'PayPal: ' + response.message) + else + context.JK.Banner.showAlert('PayPal/Sales Error', 'Please contact support@jamkazam.com') + @app.notifyServerError jqXHR, 'PayPal Communication Error' + ) + ) + .fail((jqXHR) => + @app.notifyServerError jqXHR, 'Unable to fetch carts' + ) + + + beforeShow: () -> + this.setState({sold: false}) + + onAppInit: (@app) -> + @EVENTS = context.JK.EVENTS + @rest = context.JK.Rest() + @logger = context.JK.logger + + screenBindings = + 'beforeShow': @beforeShow + 'afterShow': @afterShow + + @app.bindScreen('paypal/confirm', screenBindings) + + onUserChanged: (userState) -> + @user = userState?.user + +}) \ No newline at end of file diff --git a/web/app/assets/javascripts/react-components/ShoppingCartContents.js.jsx.coffee b/web/app/assets/javascripts/react-components/ShoppingCartContents.js.jsx.coffee new file mode 100644 index 000000000..3d8d70ada --- /dev/null +++ b/web/app/assets/javascripts/react-components/ShoppingCartContents.js.jsx.coffee @@ -0,0 +1,106 @@ +context = window +MIX_MODES = context.JK.MIX_MODES + +@ShoppingCartContents = React.createClass({ + + mixins: [Reflux.listenTo(@AppStore, "onAppInit"), Reflux.listenTo(@UserStore, "onUserChanged")] + + render: () -> + + carts = [] + + if this.props.carts? + if this.props.carts.length == 0 + carts = `
You have nothing in your cart
` + else + taxRate = 0 + if this.props.tax + taxRate = 0.0825 + + estimatedTax = 0 + estimatedTotal = 0 + + for cart in this.props.carts + cart_quantity = cart.product_info.quantity - cart.product_info.marked_for_redeem + estimatedTax += cart.product_info.price * cart_quantity * taxRate + estimatedTotal += cart.product_info.price * cart_quantity + + estimatedTax = Math.round(estimatedTax * 100) / 100 + estimatedTotal = Math.round((estimatedTotal + estimatedTax) * 100) / 100 + + for cart in this.props.carts + console.log("CART", cart) + freeNotice = null + if cart.product_info.free + freeNotice = `| (first one free)` + carts.push(`
+
+ {cart.product_info.sale_display} + {freeNotice} +
+
+ $ {Number(cart.product_info.real_price).toFixed(2)} +
+
+ {cart.quantity} +
+
+
`) + + carts.push(`
+
+ Tax +
+
+ $ {estimatedTax.toFixed(2)} +
+
+ +
+
+
`) + + carts.push(`
+
+ Total +
+
+ $ {estimatedTotal.toFixed(2)} +
+
+ +
+
+
`) + else + carts = `
Loading...
` + + `
+
+
+
+ YOUR ORDER INCLUDES: +
+
+ PRICE +
+
+ QUANTITY +
+
+ {carts} +
+
+
+
` + + onAppInit: (@app) -> + @EVENTS = context.JK.EVENTS + @rest = context.JK.Rest() + @logger = context.JK.logger + + + onUserChanged: (userState) -> + @user = userState?.user + +}) \ No newline at end of file diff --git a/web/app/assets/javascripts/react-components/TeacherProfile.js.jsx.coffee b/web/app/assets/javascripts/react-components/TeacherProfile.js.jsx.coffee index aaefaf606..43d0a0902 100644 --- a/web/app/assets/javascripts/react-components/TeacherProfile.js.jsx.coffee +++ b/web/app/assets/javascripts/react-components/TeacherProfile.js.jsx.coffee @@ -43,6 +43,7 @@ proficiencyDescriptionMap = { TILE_RATINGS: 'ratings' TILE_PRICES: 'prices' visible: false + profileClipboard: null TILES: ['about', 'experience', 'samples', 'ratings', 'prices'] @@ -65,11 +66,26 @@ proficiencyDescriptionMap = { @root = $(@getDOMNode()) @screen = $('#teacher-profile') @starbox() + @clipboard() componentDidUpdate:() -> @starbox() context.JK.popExternalLinks(@root) + @clipboard() + + clipboard: () -> + $profileLink = @root.find('.copy-profile-link') + + if $profileLink.length > 0 && !@profileClipboard? + # mount it + @profileClipboard = new Clipboard($profileLink.get(0), { + text: => + return context.JK.makeAbsolute('/client#/teacher/profile/' + @state.user.teacher?.id) + }) + else if $profileLink.length == 0 && @profileClipboard? + @profileClipboard.destroy() + @profileClipboard = null starbox:() -> $ratings = @root.find('.ratings-box') @@ -230,7 +246,9 @@ proficiencyDescriptionMap = { biography = biography.replace(/\n/g, "
") `
-

Teacher Profile {this.editProfileLink('edit profile', 'introduction')}

+ + COPY PROFILE URL TO CLIPBOARD +

Teacher Profile {this.editProfileLink('edit profile', 'introduction')}

@@ -694,6 +712,16 @@ proficiencyDescriptionMap = {
` + copyProfileLink: (e) -> + + e.preventDefault() + + @app.layout.notify({ + title: 'Teacher Profile Link Copied', + text: "Your clipboard now has a link to this teacher that you can share with anyone." + }) + + selectionMade: (selection, e) -> e.preventDefault() diff --git a/web/app/assets/javascripts/recordingModel.js b/web/app/assets/javascripts/recordingModel.js index 61114fdb8..18fbebff8 100644 --- a/web/app/assets/javascripts/recordingModel.js +++ b/web/app/assets/javascripts/recordingModel.js @@ -102,7 +102,7 @@ var details = { clientId: app.clientId, reason: 'rest', detail: arguments, isRecording: false } $self.triggerHandler('startedRecording', details); currentlyRecording = false; - context.RecordingActions.startedRecording(details); + context.RecordingActions.startedRecording(details); }) diff --git a/web/app/assets/javascripts/web/web.js b/web/app/assets/javascripts/web/web.js index 2f2917a2d..38f03af42 100644 --- a/web/app/assets/javascripts/web/web.js +++ b/web/app/assets/javascripts/web/web.js @@ -9,7 +9,7 @@ //= require jquery.queryparams //= require jquery.hoverIntent //= require jquery.cookie -//= require jquery.clipboard +//= require clipboard //= require jquery.easydropdown //= require jquery.carousel-1.1 //= require jquery.mousewheel-3.1.9 diff --git a/web/app/assets/stylesheets/client/checkout_payment.scss b/web/app/assets/stylesheets/client/checkout_payment.scss index c1f261031..1485e86af 100644 --- a/web/app/assets/stylesheets/client/checkout_payment.scss +++ b/web/app/assets/stylesheets/client/checkout_payment.scss @@ -33,6 +33,24 @@ } } + .paypal-region { + text-align: center; + margin:10px auto 0; + /**margin: 10px auto 0; + padding: 10px 10px 5px; + background-color: white; + border-radius: 8px; + border-color: #ccc; + border-style: solid; + border-width: 3px; + width:145px;*/ + } + + .or-text { + margin: 60px auto 0; + text-align:center; + } + h2 { color:white; background-color:#4d4d4d; diff --git a/web/app/assets/stylesheets/client/react-components/PayPalConfirmationScreen.scss b/web/app/assets/stylesheets/client/react-components/PayPalConfirmationScreen.scss new file mode 100644 index 000000000..04403000f --- /dev/null +++ b/web/app/assets/stylesheets/client/react-components/PayPalConfirmationScreen.scss @@ -0,0 +1,78 @@ +@import "client/common.scss"; + +[data-react-class="PayPalConfirmationScreen"] { + height: 100%; + overflow: scroll; + + .content-body-scroller { + height: calc(100% - 30px) ! important; // 15px top and bottom padding, and 48px used by .controls + padding: 15px 30px; + } + + .confirm-header { + color: white; + font-size: 20px; + } + + .controls.bottom { + margin-top: 20px; + } + + .place-order-btn { + text-align: center; + margin-right:0; + } + + .or-holder { + margin-top: 20px; + text-align: center; + } + + .cancel-order-btn { + margin-top: 20px; + text-align: center; + } + .shopping-cart-contents { + @include border-box_sizing; + width: 50%; + margin-top:20px; + } + .controls { + @include border-box_sizing; + width:50%; + a { + float:right; + } + } + .loading-indicator { + margin-bottom:20px; + padding-bottom:20px; + } + + .sold-notice { + h2 { + font-size:30px; + text-align:center; + } + } + .download-jamkazam { + color:$ColorLink; + border-radius: 4px; + border-style:solid; + border-color:#AAA; + border-width:1px; + padding:10px; + margin-top:20px; + display:inline-block; + } + + .download-jamkazam-wrapper, .back-to-browsing { + text-align:center; + display:block; + margin-top:35px; + + &.hidden { + display:none; + } + } +} diff --git a/web/app/assets/stylesheets/client/react-components/ShoppingCartContents.scss b/web/app/assets/stylesheets/client/react-components/ShoppingCartContents.scss new file mode 100644 index 000000000..64a37bd95 --- /dev/null +++ b/web/app/assets/stylesheets/client/react-components/ShoppingCartContents.scss @@ -0,0 +1,64 @@ +@import "client/common.scss"; + +.shopping-cart-contents { + + background-color:#262626; + border-width:0 1px 0 0; + border-style:solid; + border-color:#333; + padding:20px 20px 0; + .cart-item-caption { + width: 50%; + text-align: left; + float: left; + margin-bottom: 10px; + @include border_box_sizing; + } + + .first-one-free { + font-size: 14px; + font-style: italic; + margin-left: 15px; + } + + .cart-item-price { + width: 25%; + text-align: right; + float: left; + padding: 0 10px; + margin-bottom: 10px; + @include border_box_sizing; + } + + .cart-item-quantity { + width: 10%; + text-align: right; + float: left; + padding: 0 10px; + margin-bottom: 10px; + @include border_box_sizing; + } + + .cart-items { + margin-top: 10px; + padding-left: 10px; + } + + .cart-item { + margin-top: 10px; + } + + .no-cart-items { + } + + .tax-total { + margin-top:10px; + border-width:1px 0 0 0; + border-color:white; + border-style:solid; + padding-top:10px; + } + .cart-item.total { + margin-top:5px; + } +} diff --git a/web/app/assets/stylesheets/client/react-components/TeacherProfile.scss b/web/app/assets/stylesheets/client/react-components/TeacherProfile.scss index 3a4618776..b1f2fbcec 100644 --- a/web/app/assets/stylesheets/client/react-components/TeacherProfile.scss +++ b/web/app/assets/stylesheets/client/react-components/TeacherProfile.scss @@ -199,6 +199,9 @@ position:absolute; } + .copy-profile-link { + float:right; + } .spinner-large { width:200px; diff --git a/web/app/controllers/api_lesson_sessions_controller.rb b/web/app/controllers/api_lesson_sessions_controller.rb index 3fcd377a0..2a74b147f 100644 --- a/web/app/controllers/api_lesson_sessions_controller.rb +++ b/web/app/controllers/api_lesson_sessions_controller.rb @@ -15,7 +15,6 @@ class ApiLessonSessionsController < ApiController render "api_lesson_sessions/index", :layout => nil end - def show end @@ -72,13 +71,13 @@ class ApiLessonSessionsController < ApiController if params[:update_all] # check if the next scheduled lesson is doable - if 24.hours.from_now > @lesson_session.lesson_booking.next_lesson.music_session.scheduled_start + if 15.minutes.from_now > @lesson_session.lesson_booking.next_lesson.music_session.scheduled_start response = {message: 'time_limit'} render :json => response, :status => 422 return end else - if 24.hours.from_now > @lesson_session.music_session.scheduled_start + if 15.minutes.from_now > @lesson_session.music_session.scheduled_start response = {message: 'time_limit'} render :json => response, :status => 422 return diff --git a/web/app/controllers/api_pay_pal_controller.rb b/web/app/controllers/api_pay_pal_controller.rb new file mode 100644 index 000000000..8666cfd13 --- /dev/null +++ b/web/app/controllers/api_pay_pal_controller.rb @@ -0,0 +1,136 @@ +class ApiPayPalController < ApiController + + before_filter :api_signed_in_user + + respond_to :json + + + def log + @log || Logging.logger[VanillaForumsController] + end + + def start_checkout + cancel_path = params[:path] ? params[:path] : ERB::Util.url_encode('/client#/checkoutPayment') + + tax = true + 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) + + + @api = PayPal::SDK::Merchant::API.new + @set_express_checkout = @api.build_set_express_checkout( + { + :Version => "117.0", + :SetExpressCheckoutRequestDetails => + { + :ReturnURL => ApplicationHelper.base_uri(request) + '/auth/paypal/checkout', + :CancelURL => ApplicationHelper.base_uri(request) + '/auth/paypal/checkout?cancel=1&path=' + cancel_path, + # :NoShipping => "1", + # :ReqConfirmShipping => "0", + # :ReqBillingAddress => "1", + :PaymentDetails => + [ + { + :OrderTotal => { + :currencyID => "USD", + :value => total + }, + :PaymentAction => "Sale" + } + ] + } + } + ) + @set_express_checkout_response = @api.set_express_checkout(@set_express_checkout) + + log.info("User #{current_user.email}, SetExpressCheckout #{@set_express_checkout_response.inspect}") + + if @set_express_checkout_response.Ack == 'Failure' + render json: {message: @set_express_checkout_response.Errors[0].LongMessage}, status: 422 + return + end + + redirect_to Rails.configuration.paypal_express_url + '&token=' + ERB::Util.url_encode(@set_express_checkout_response.Token) + end + + # called by frontend after the user comes back from initial express page + def checkout_detail + # here we can see if they will pay tax + + if !current_user.has_paypal_auth? + render json: {}, :status => 404 + return + end + 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) + + puts @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 + else + render json: {message: @response.Errors[0].LongMessage}, status: 422 + return + end + + log.debug("User #{current_user.email}, GetExpressCheckout: #{@get_express_checkout_details_response.inspect}") + + render json: {tax: tax} + end + + # called by frontend when the user selects finally 'confirm purchase' (PLACE ORDER btn) + def confirm_purchase + if !current_user.has_paypal_auth? + render json: {}, :status => 404 + return + end + + error = nil + response = {jam_tracks: [], gift_cards: []} + + #if Sale.is_mixed(current_user.shopping_carts) + # msg = "has free and non-free items. Try removing non-free items." + # render json: {message: "Cart " + msg, errors: {cart: [msg]}}, :status => 404 + # return + #end + + begin + sales = Sale.place_order(current_user, current_user.shopping_carts, true) + rescue RecurlyClientError => e + render json: {message: e.errors[:message]}, :status => 422 + return + rescue PayPalClientError => x + render json: {message: x.errors[:message]}, :status => 422 + return + end + + + sales.each do |sale| + sale.sale_line_items.each do |line_item| + if line_item.is_jam_track? + jam_track = line_item.product + jam_track_right = jam_track.right_for_user(current_user) + response[:jam_tracks] << {name: jam_track.name, id: jam_track.id, jam_track_right_id: jam_track_right.id, version: jam_track.version} + elsif line_item.is_gift_card? + gift_card = line_item.product + response[:gift_cards] << {name: gift_card.name, id: gift_card.id} + else + raise 'unknown sale line item type: ' + line_item.product_type + end + end + end + + set_purchased_jamtrack_cookie + render :json => response, :status => 200 + end +end \ No newline at end of file diff --git a/web/app/controllers/sessions_controller.rb b/web/app/controllers/sessions_controller.rb index 733573fa6..45541b03f 100644 --- a/web/app/controllers/sessions_controller.rb +++ b/web/app/controllers/sessions_controller.rb @@ -1,6 +1,8 @@ # this is not a jam session - this is an 'auth session' class SessionsController < ApplicationController + before_filter :api_signed_in_user, only: :paypal_express_checkout + layout "web" def signin @@ -37,6 +39,42 @@ class SessionsController < ApplicationController end end + def paypal_express_checkout + # should get 'token' and 'PayerID' on success + + # on failure, cancel=1 + + if params[:cancel] == '1' || params[:cancel] == 1 + redirect_to params[:path] ? params[:path] : '/client#/jamtrack' + return + end + + authorization = current_user.paypal_auth + + # Always make and save a new authorization. This is because they expire, and honestly there's no cost + # to just making and saving it. + + user_auth_hash = { + :provider => 'paypal', + :uid => params[:PayerID], + :token => params[:token], + :refresh_token => nil, + :token_expiration => 3.hours.from_now, # according to paypal docs, a token is good for 3 hours + :secret => nil + } + + if authorization.nil? + authorization = current_user.user_authorizations.build(user_auth_hash) + authorization.save + else + authorization.token = user_auth_hash[:token] + authorization.token_expiration = user_auth_hash[:token_expiration] + authorization.uid = user_auth_hash[:uid] + authorization.save + end + + redirect_to '/client#/paypal/confirm' + end # OAuth docs # http://net.tutsplus.com/tutorials/ruby/how-to-use-omniauth-to-authenticate-your-users/ diff --git a/web/app/views/clients/_checkout_payment.html.slim b/web/app/views/clients/_checkout_payment.html.slim index 5f1d1d323..e33e7521e 100644 --- a/web/app/views/clients/_checkout_payment.html.slim +++ b/web/app/views/clients/_checkout_payment.html.slim @@ -120,6 +120,13 @@ div layout="screen" layout-id="checkoutPayment" id="checkoutPaymentScreen" class .divSaveCardHelper label for="save-card" Save card for future use .clearall + + - if !Rails.application.config.paypal_admin_only || any_user.admin + .or-text or instead use: + .paypal-region + a href="/paypal/checkout/start" data-paypal-button="true" + img src="https://www.paypalobjects.com/webstatic/en_US/i/btn/png/gold-pill-paypalcheckout-34px.png" alt="PayPal Checkout" + a .clearall .clearall .row.second diff --git a/web/app/views/clients/_paypal_confirmation.html.slim b/web/app/views/clients/_paypal_confirmation.html.slim new file mode 100644 index 000000000..1801ad234 --- /dev/null +++ b/web/app/views/clients/_paypal_confirmation.html.slim @@ -0,0 +1,8 @@ +.screen.secondary layout='screen' layout-id='paypal/confirm' + .content + .content-head + .content-icon=image_tag("content/icon_jamtracks.png", height: 19, width: 19) + h1 confirm payment + = render "screen_navigation" + .content-body + = react_component 'PayPalConfirmationScreen', {} \ No newline at end of file diff --git a/web/app/views/clients/index.html.erb b/web/app/views/clients/index.html.erb index 220bca602..b32625196 100644 --- a/web/app/views/clients/index.html.erb +++ b/web/app/views/clients/index.html.erb @@ -58,6 +58,7 @@ <%= render "jamtrack_search" %> <%= render "jamtrack_filter" %> <%= render "jamtrack_landing" %> +<%= render "paypal_confirmation" %> <%= render "shopping_cart" %> <%= render "checkout_signin" %> <%= render "checkout_payment" %> diff --git a/web/app/views/dialogs/_shareDialog.html.erb b/web/app/views/dialogs/_shareDialog.html.erb index 589e6b2d3..af94b48fe 100644 --- a/web/app/views/dialogs/_shareDialog.html.erb +++ b/web/app/views/dialogs/_shareDialog.html.erb @@ -38,7 +38,7 @@