diff --git a/db/manifest b/db/manifest index 0e7ed7388..c17ed2029 100755 --- a/db/manifest +++ b/db/manifest @@ -345,4 +345,5 @@ lessons.sql lessons_unread_messages.sql track_school_signups.sql add_test_drive_types.sql -updated_subjects.sql \ No newline at end of file +updated_subjects.sql +update_payment_history.sql \ No newline at end of file diff --git a/db/up/update_payment_history.sql b/db/up/update_payment_history.sql new file mode 100644 index 000000000..f81d81339 --- /dev/null +++ b/db/up/update_payment_history.sql @@ -0,0 +1 @@ +ALTER TABLE charges ADD COLUMN user_id VARCHAR(64) REFERENCES users(id) NOT NULL; \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/charge.rb b/ruby/lib/jam_ruby/models/charge.rb index 5af2b3b5a..1f8a4d4c0 100644 --- a/ruby/lib/jam_ruby/models/charge.rb +++ b/ruby/lib/jam_ruby/models/charge.rb @@ -1,6 +1,8 @@ module JamRuby class Charge < ActiveRecord::Base + belongs_to :user, class_name: "JamRuby::User" + validates :sent_billing_notices, inclusion: {in: [true, false]} def max_retries diff --git a/ruby/lib/jam_ruby/models/lesson_booking.rb b/ruby/lib/jam_ruby/models/lesson_booking.rb index 206bacb6a..3fc16b1ca 100644 --- a/ruby/lib/jam_ruby/models/lesson_booking.rb +++ b/ruby/lib/jam_ruby/models/lesson_booking.rb @@ -61,11 +61,11 @@ module JamRuby validate :validate_lesson_booking_slots validate :validate_lesson_length validate :validate_payment_style + validate :validate_uncollectables validate :validate_accepted, :if => :accepting validate :validate_canceled, :if => :canceling - before_save :before_save before_validation :before_validation after_create :after_create @@ -603,7 +603,7 @@ module JamRuby #end elsif is_test_drive? if user.has_requested_test_drive?(teacher) && !user.admin - errors.add(:user, "has a requested TestDrive with this teacher") + errors.add(:user, "have a requested TestDrive with this teacher") end if !user.has_test_drives? && !user.can_buy_test_drive? errors.add(:user, "have no remaining test drives") @@ -651,6 +651,11 @@ module JamRuby end end + def validate_uncollectables + if user.uncollectables.count > 0 + errors.add(:user, 'have unpaid lessons.') + end + end def self.book_free(user, teacher, lesson_booking_slots, description) self.book(user, teacher, LessonBooking::LESSON_TYPE_FREE, lesson_booking_slots, false, 30, PAYMENT_STYLE_ELSEWHERE, description) diff --git a/ruby/lib/jam_ruby/models/lesson_package_purchase.rb b/ruby/lib/jam_ruby/models/lesson_package_purchase.rb index 33e28f395..cda42d86a 100644 --- a/ruby/lib/jam_ruby/models/lesson_package_purchase.rb +++ b/ruby/lib/jam_ruby/models/lesson_package_purchase.rb @@ -33,11 +33,14 @@ module JamRuby end def create_charge - self.lesson_payment_charge = LessonPaymentCharge.new - lesson_payment_charge.amount_in_cents = 0 - lesson_payment_charge.fee_in_cents = 0 - lesson_payment_charge.lesson_package_purchase = self - lesson_payment_charge.save! + if self.lesson_booking.is_monthly_payment? + self.lesson_payment_charge = LessonPaymentCharge.new + lesson_payment_charge.user = user + lesson_payment_charge.amount_in_cents = 0 + lesson_payment_charge.fee_in_cents = 0 + lesson_payment_charge.lesson_package_purchase = self + lesson_payment_charge.save! + end end def add_test_drives @@ -94,14 +97,17 @@ module JamRuby (price * 100).to_i end - def description(lesson_booking) - lesson_package_type.description(lesson_booking) + def description(lesson_booking, time = false) + lesson_package_type.description(lesson_booking, time) end def stripe_description(lesson_booking) description(lesson_booking) end + def timed_description + "Lessons for the month of #{self.month_name} with #{self.lesson_booking.student.name}" + end def month_name if recurring @@ -116,6 +122,7 @@ module JamRuby end + def bill_monthly(force = false) lesson_payment_charge.charge(force) diff --git a/ruby/lib/jam_ruby/models/lesson_payment_charge.rb b/ruby/lib/jam_ruby/models/lesson_payment_charge.rb index f68e84fc6..e1cda471a 100644 --- a/ruby/lib/jam_ruby/models/lesson_payment_charge.rb +++ b/ruby/lib/jam_ruby/models/lesson_payment_charge.rb @@ -9,7 +9,7 @@ module JamRuby end def charged_user - @charged_user ||= target.student + user end def resolve_target @@ -31,6 +31,10 @@ module JamRuby charged_user end + def teacher + target.teacher + end + def is_lesson? !lesson_session.nil? end @@ -85,5 +89,13 @@ module JamRuby end end end + + def description + target.timed_description + end + + def expected_price_in_cents + target.lesson_booking.distribution_price_in_cents(target) + end end end \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/lesson_session.rb b/ruby/lib/jam_ruby/models/lesson_session.rb index 641d4bdb7..53bb1462f 100644 --- a/ruby/lib/jam_ruby/models/lesson_session.rb +++ b/ruby/lib/jam_ruby/models/lesson_session.rb @@ -5,13 +5,13 @@ module JamRuby include HtmlSanitize html_sanitize strict: [:cancel_message] - attr_accessor :accepting, :creating, :countering, :countered_slot, :countered_lesson, :canceling + attr_accessor :accepting, :creating, :countering, :countered_slot, :countered_lesson, :canceling, :assigned_student @@log = Logging.logger[LessonSession] delegate :sent_billing_notices, :last_billing_attempt_at, :billing_attempts, :billing_should_retry, :billed_at, :billing_error_detail, :billing_error_reason, :is_card_declined?, :is_card_expired?, :last_billed_at_date, :sent_billing_notices, to: :lesson_payment_charge, allow_nil: true - delegate :is_test_drive?, :is_single_free?, :is_normal?, :approved_before?, :is_active?, :recurring, to: :lesson_booking + delegate :is_test_drive?, :is_single_free?, :is_normal?, :approved_before?, :is_active?, :recurring, :is_monthly_payment?, to: :lesson_booking delegate :pretty_scheduled_start, to: :music_session @@ -75,8 +75,9 @@ module JamRuby scope :past_cancel_window, -> { joins(:music_session).where('music_sessions.scheduled_start > ?', 24.hours.from_now) } def create_charge - if !is_test_drive? + if !is_test_drive? && !is_monthly_payment? self.lesson_payment_charge = LessonPaymentCharge.new + lesson_payment_charge.user = @assigned_student lesson_payment_charge.amount_in_cents = 0 lesson_payment_charge.fee_in_cents = 0 lesson_payment_charge.lesson_session = self @@ -432,6 +433,7 @@ module JamRuby lesson_session.teacher = booking.teacher lesson_session.status = booking.status lesson_session.slot = booking.default_slot + lesson_session.assigned_student = booking.student if booking.is_test_drive? lesson_session.lesson_package_purchase = booking.student.most_recent_test_drive_purchase end @@ -711,6 +713,19 @@ module JamRuby lesson_booking.lesson_package_type.description(lesson_booking) end + def timed_description + if is_test_drive? + "TestDrive session with #{self.lesson_booking.student.name} on #{self.scheduled_start.to_date.strftime('%B %d, %Y')}" + else + if self.lesson_booking.is_monthly_payment? + "Monthly Lesson with #{self.lesson_booking.student.name} on #{self.scheduled_start.to_date.strftime('%B %d, %Y')}" + else + "Lesson with #{self.lesson_booking.student.name} on #{self.scheduled_start.to_date.strftime('%B %d, %Y')}" + end + end + end + + def stripe_description(lesson_booking) description(lesson_booking) end diff --git a/ruby/lib/jam_ruby/models/payment_history.rb b/ruby/lib/jam_ruby/models/payment_history.rb index 4862e4dd1..ab6013d62 100644 --- a/ruby/lib/jam_ruby/models/payment_history.rb +++ b/ruby/lib/jam_ruby/models/payment_history.rb @@ -5,6 +5,7 @@ module JamRuby belongs_to :sale belongs_to :recurly_transaction_web_hook + belongs_to :charge def self.index(user, params = {}) @@ -14,7 +15,7 @@ module JamRuby limit = limit.to_i query = PaymentHistory.limit(limit) - .includes(sale: [:sale_line_items], recurly_transaction_web_hook:[]) + .includes(sale: [:sale_line_items], recurly_transaction_web_hook:[], charge:[]) .where(user_id: user.id) .where("transaction_type = 'sale' OR transaction_type = 'refund' OR transaction_type = 'void'") .order('created_at DESC') diff --git a/ruby/lib/jam_ruby/models/teacher_distribution.rb b/ruby/lib/jam_ruby/models/teacher_distribution.rb index 80b14fa01..e7001ad65 100644 --- a/ruby/lib/jam_ruby/models/teacher_distribution.rb +++ b/ruby/lib/jam_ruby/models/teacher_distribution.rb @@ -104,17 +104,9 @@ module JamRuby def description if lesson_session - if lesson_session.lesson_booking.is_test_drive? - "TestDrive session with #{lesson_session.lesson_booking.student.name} on #{lesson_session.scheduled_start.to_date.strftime('%B %d, %Y')}" - elsif lesson_session.lesson_booking.is_normal? - if lesson_session.lesson_booking.is_weekly_payment? || lesson_session.lesson_booking.is_monthly_payment? - raise "Should not be here" - else - "Lesson with #{lesson_session.lesson_booking.student.name} on #{lesson_session.scheduled_start.to_date.strftime('%B %d, %Y')}" - end - end + lesson_session.timed_description else - "Lessons for the month of #{lesson_package_purchase.month_name} with #{lesson_package_purchase.lesson_booking.student.name}" + lesson_package_purchase.description end end end diff --git a/ruby/lib/jam_ruby/models/teacher_payment.rb b/ruby/lib/jam_ruby/models/teacher_payment.rb index 789be57d7..fe2b603c3 100644 --- a/ruby/lib/jam_ruby/models/teacher_payment.rb +++ b/ruby/lib/jam_ruby/models/teacher_payment.rb @@ -80,6 +80,7 @@ module JamRuby if payment.teacher_payment_charge.nil? charge = TeacherPaymentCharge.new + charge.user = teacher charge.amount_in_cents = payment.amount_in_cents charge.fee_in_cents = payment.fee_in_cents charge.teacher_payment = payment diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index bd84e79e3..98120a686 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -1957,6 +1957,7 @@ module JamRuby def card_approved(token, zip, booking_id) approved_booking = nil + found_uncollectables = nil User.transaction do self.stripe_token = token if token self.stripe_zip_code = zip if zip @@ -1970,9 +1971,16 @@ module JamRuby approved_booking.card_approved end end + + if uncollectables.count > 0 + found_uncollectables = uncollectables + uncollectables.update_all(billing_should_retry: true) + else + found_uncollectables = nil + end end end - approved_booking + [approved_booking, found_uncollectables] end def update_name(name) @@ -2002,6 +2010,7 @@ module JamRuby intent = nil purchase = nil lesson_package_type = nil + uncollectables = nil User.transaction do if params[:name].present? @@ -2010,7 +2019,7 @@ module JamRuby end end - booking = card_approved(params[:token], params[:zip], params[:booking_id]) + booking, uncollectables = card_approved(params[:token], params[:zip], params[:booking_id]) if params[:test_drive] self.reload if booking @@ -2035,7 +2044,7 @@ module JamRuby end - {lesson: booking, test_drive: test_drive, purchase: purchase, lesson_package_type: lesson_package_type} + {lesson: booking, test_drive: test_drive, purchase: purchase, lesson_package_type: lesson_package_type, uncollectables: uncollectables} end def requested_test_drive(teacher = nil) @@ -2095,6 +2104,10 @@ module JamRuby total_test_drives - remaining_test_drives end + def uncollectables(limit = 10) + LessonPaymentCharge.where(user_id:self.id).order(:created_at).where('billing_attempts > 0').where(billed: false).limit(limit) + end + def has_rated_teacher(teacher) if teacher.is_a?(JamRuby::User) teacher = teacher.teacher diff --git a/ruby/spec/factories.rb b/ruby/spec/factories.rb index 636414916..f199e041b 100644 --- a/ruby/spec/factories.rb +++ b/ruby/spec/factories.rb @@ -1010,6 +1010,13 @@ FactoryGirl.define do factory :teacher_payment_charge, parent: :charge, class: 'JamRuby::TeacherPaymentCharge' do type 'JamRuby::TeacherPaymentCharge' + association :user, factory: :user + end + + + factory :lesson_payment_charge, parent: :charge, class: 'JamRuby::LessonPaymentCharge' do + type 'JamRuby::LessonPaymentCharge' + association :user, factory: :user end diff --git a/ruby/spec/jam_ruby/models/user_spec.rb b/ruby/spec/jam_ruby/models/user_spec.rb index 36e3cf949..d55ff6956 100644 --- a/ruby/spec/jam_ruby/models/user_spec.rb +++ b/ruby/spec/jam_ruby/models/user_spec.rb @@ -842,6 +842,45 @@ describe User do end end + describe "uncollectables" do + let(:user) {FactoryGirl.create(:user)} + let(:teacher) {FactoryGirl.create(:teacher_user)} + + + it "empty" do + user.uncollectables.count.should eql 0 + end + + it "one" do + lesson_session = normal_lesson(user, teacher) + lesson_session.lesson_payment_charge.user.should eql user + lesson_session.lesson_payment_charge.billing_attempts = 1 + lesson_session.lesson_payment_charge.save! + uncollectables = user.uncollectables + uncollectables.count.should eql 1 + uncollectable = uncollectables[0] + uncollectable.description.should_not be_nil + uncollectable.expected_price_in_cents.should eql 3000 + uncollectable.is_card_declined?.should be_false + end + + it "for monthly" do + lesson_session = monthly_lesson(user, teacher) + lesson_session.booked_price.should eql 30.00 + LessonBooking.hourly_check + lesson_session.lesson_payment_charge.should be_nil + purchases=LessonPackagePurchase.where(user_id: user.id) + purchases.count.should eql 1 + purchases[0].lesson_payment_charge.billing_attempts = 1 + purchases[0].lesson_payment_charge.save! + uncollectables = user.uncollectables + uncollectables.count.should eql 1 + uncollectable = uncollectables[0] + uncollectable.description.should_not be_nil + uncollectable.expected_price_in_cents.should eql 3000 + uncollectable.is_card_declined?.should be_false + end + end =begin describe "update avatar" do diff --git a/ruby/spec/support/lesson_session.rb b/ruby/spec/support/lesson_session.rb index 1e13a3e15..291c466a5 100644 --- a/ruby/spec/support/lesson_session.rb +++ b/ruby/spec/support/lesson_session.rb @@ -74,6 +74,41 @@ def normal_lesson(user, teacher, slots = nil) lesson.reload lesson.slot.should eql slots[0] lesson.status.should eql LessonSession::STATUS_APPROVED + lesson.music_session.should_not be_nil + + lesson +end + + +def monthly_lesson(user, teacher, slots = nil) + + if slots.nil? + slots = [] + slots << FactoryGirl.build(:lesson_booking_slot_recurring) + slots << FactoryGirl.build(:lesson_booking_slot_recurring) + end + + if user.stored_credit_card == false + user.stored_credit_card = true + user.save! + end + + booking = LessonBooking.book_normal(user, teacher, slots, "Hey I've heard of you before.", true, LessonBooking::PAYMENT_STYLE_MONTHLY, 60) + # puts "NORMAL BOOKING #{booking.errors.inspect}" + booking.errors.any?.should be_false + lesson = booking.lesson_sessions[0] + booking.card_presumed_ok.should be_true + + #if user.most_recent_test_drive_purchase.nil? + # LessonPackagePurchase.create(user, booking, LessonPackageType.test_drive_4) + #end + + lesson.accept({message: 'Yeah I got this', slot: slots[0]}) + lesson.errors.any?.should be_false + lesson.reload + lesson.slot.should eql slots[0] + lesson.status.should eql LessonSession::STATUS_APPROVED + lesson.music_session.should_not be_nil lesson end \ No newline at end of file diff --git a/web/app/assets/javascripts/jam_rest.js b/web/app/assets/javascripts/jam_rest.js index 825e5d817..677e0c4b5 100644 --- a/web/app/assets/javascripts/jam_rest.js +++ b/web/app/assets/javascripts/jam_rest.js @@ -2220,6 +2220,16 @@ }); } + function getUncollectables(options) { + options = options || {} + return $.ajax({ + type: "GET", + url: "/api/lesson_sessions/uncollectable", + dataType: "json", + contentType: 'application/json' + }); + } + function getLesson(options) { options = options || {} @@ -2672,6 +2682,7 @@ this.counterLessonBooking = counterLessonBooking; this.submitStripe = submitStripe; this.getLessonSessions = getLessonSessions; + this.getUncollectables = getUncollectables; this.getLesson = getLesson; this.getLessonAnalysis = getLessonAnalysis; this.updateLessonSessionUnreadMessages = updateLessonSessionUnreadMessages; diff --git a/web/app/assets/javascripts/react-components/AccountPaymentHistoryScreen.js.jsx.coffee b/web/app/assets/javascripts/react-components/AccountPaymentHistoryScreen.js.jsx.coffee index 47be61e3f..13a6d8178 100644 --- a/web/app/assets/javascripts/react-components/AccountPaymentHistoryScreen.js.jsx.coffee +++ b/web/app/assets/javascripts/react-components/AccountPaymentHistoryScreen.js.jsx.coffee @@ -10,7 +10,7 @@ profileUtils = context.JK.ProfileUtils @AccountPaymentHistoryScreen = React.createClass({ mixins: [ - #ICheckMixin, + ICheckMixin, Reflux.listenTo(AppStore, "onAppInit"), Reflux.listenTo(UserStore, "onUserChanged") ] @@ -23,21 +23,62 @@ profileUtils = context.JK.ProfileUtils TILE_PAYMENTS_TO_JAMKAZAM: 'payments to jamkazam' TILE_PAYMENT_METHOD: 'payment method' - STUDENT_TILES: ['payments to jamkazam', 'payment method'] + STUDENT_TILES: ['payment method', 'payments to jamkazam'] TEACHER_TILES: ['payments to jamkazam', 'payments to you'] onAppInit: (@app) -> @app.bindScreen('account/paymentHistory', {beforeShow: @beforeShow, afterShow: @afterShow, beforeHide: @beforeHide}) onUserChanged: (userState) -> - @setState({user: userState?.user}) + if !@shouldShowNameSet + @shouldShowNameSet = true + if userState?.user? + username = userState.user.name + first_name = userState.user.first_name + last_name = userState.user.last_name + shouldShowName = !username? || username.trim() == '' || username.toLowerCase().indexOf('anonymous') > -1 + else + shouldShowName = @state.shouldShowName + + @setState({user: userState?.user, shouldShowName: shouldShowName}) + componentDidMount: () -> - #@checkboxes = [{selector: 'input.slot-decision', stateKey: 'slot-decision'}] + @checkboxes = [{selector: 'input.billing-address-in-us', stateKey: 'billingInUS'}] + @root = $(@getDOMNode()) @endOfList = @root.find('.end-of-payments-list') @contentBodyScroller = @root - #@iCheckify() + @root = $(@getDOMNode()) + @iCheckify() + + componentDidUpdate: (prevProps, prevState) -> + @iCheckify() + + $expiration = @root.find('input.expiration') + if !$expiration.data('payment-applied') + $expiration.payment('formatCardExpiry').data('payment-applied', true) + $cardNumber = @root.find("input.card-number") + if !$cardNumber.data('payment-applied') + $cardNumber.payment('formatCardNumber').data('payment-applied', true) + $cvv = @root.find("input.cvv") + if !$cvv.data('payment-applied') + $cvv.payment('formatCardCVC').data('payment-applied', true) + + if @currentNext() == null + @contentBodyScroller.off('scroll') + if @state[@getCurrentPageName()] == 1 and @getCurrentList().length == 0 + @endOfList.show() + logger.debug("PaymentHistoryScreen: empty search") + else if @state[@getCurrentPageName()] > 0 + logger.debug("end of search") + @endOfList.show() + else + @registerInfiniteScroll(@contentBodyScroller) + + if @activeTile(prevState.selected) != @activeTile() && @getCurrentList().length == 0 + @refresh() + registerInfiniteScroll:() -> $scroller = @contentBodyScroller @@ -59,24 +100,6 @@ profileUtils = context.JK.ProfileUtils @incrementCurrentPage() @refresh() - - componentDidUpdate: (prevProps, prevState) -> - #@iCheckify() - - if @currentNext() == null - @contentBodyScroller.off('scroll') - if @state[@getCurrentPageName()] == 1 and @getCurrentList().length == 0 - @endOfList.show() - logger.debug("PaymentHistoryScreen: empty search") - else if @state[@getCurrentPageName()] > 0 - logger.debug("end of search") - @endOfList.show() - else - @registerInfiniteScroll(@contentBodyScroller) - - if @activeTile(prevState.selected) != @activeTile() && @getCurrentList().length == 0 - @refresh() - checkboxChanged: (e) -> checked = $(e.target).is(':checked') @@ -86,6 +109,7 @@ profileUtils = context.JK.ProfileUtils beforeHide: (e) -> @screenVisible = false + @resetErrors() beforeShow: (e) -> @@ -93,7 +117,15 @@ profileUtils = context.JK.ProfileUtils @clearResults() @screenVisible = true @refresh() + @getUncollectables() + resetErrors: () -> + @setState({ccError: null, cvvError: null, expiryError: null, billingInUSError: null, zipCodeError: null, nameError: null}) + + checkboxChanged: (e) -> + checked = $(e.target).is(':checked') + + @setState({billingInUS: checked}) refresh: () -> @buildQuery() @@ -116,6 +148,11 @@ profileUtils = context.JK.ProfileUtils .done(@teacherDistributionsDone) .fail(@teacherDistributionsFail) + getUncollectables: () -> + rest.getUncollectables({}) + .done(@uncollectablesDone) + .fail(@uncollectablesFail) + salesHistoryDone:(response) -> @refreshing = false this.setState({salesNext: response.next, sales: this.state.sales.concat(response.entries)}) @@ -132,9 +169,14 @@ profileUtils = context.JK.ProfileUtils @refreshing = false @app.notifyServerError jqXHR, 'Payments to You Unavailable' + uncollectablesDone: (response) -> + this.setState({uncollectables: response}) + + uncollectablesFail: (jqXHR) -> + @app.notifyServerError jqXHR, 'Unable to fetch uncollectable info' clearResults:() -> - this.setState({salesCurrentPage: 0, sales: [], distributionsCurrentPage: 0, distributions: [], salesNext: null, distributionsNext: null}) + this.setState({salesCurrentPage: 0, sales: [], distributionsCurrentPage: 0, distributions: [], salesNext: null, distributionsNext: null, updating: false}) buildQuery:(page = @getCurrentPage()) -> @currentQuery = this.defaultQuery(page) @@ -182,6 +224,10 @@ profileUtils = context.JK.ProfileUtils else null + onClick: (e) -> + e.preventDefault() + + context.location.href = '/client#/account' getInitialState: () -> { user: null, @@ -192,7 +238,11 @@ profileUtils = context.JK.ProfileUtils distributionsNext: null sales: [], distributions: [] - selected: 'payments to jamkazam' + selected: 'payments to jamkazam', + updating: false, + billingInUS: true, + userWantsUpdateCC: false, + uncollectables: [] } onCancel: (e) -> @@ -272,17 +322,147 @@ profileUtils = context.JK.ProfileUtils Next
No more payment history
- BACK + BACK

` paymentMethod: () -> + disabled = @state.updating || @reuseStoredCard() + submitClassNames = {'button-orange': true, 'purchase-btn': true, disabled: disabled && @state.updating} + updateCardClassNames = {'button-grey': true, 'update-btn': true, disabled: disabled && @state.updating} + backClassNames = {'button-grey': true, disabled: disabled && @state.updating} + + cardNumberFieldClasses = {field: true, "card-number": true, error: @state.ccError} + expirationFieldClasses = {field: true, "expiration": true, error: @state.expiryError} + cvvFieldClasses = {field: true, "card-number": true, error: @state.cvvError} + inUSClasses = {field: true, "billing-in-us": true, error: @state.billingInUSError} + zipCodeClasses = {field: true, "zip-code": true, error: @state.zipCodeError} + nameClasses= {field: true, "name": true, error: @state.nameError} + formClasses= {stored: @reuseStoredCard()} + leftColumnClasses = {column: true, 'column-left': true, stored: @reuseStoredCard()} + rightColumnClasses = {column: true, 'column-right': true, stored: @reuseStoredCard()} + + if @state.uncollectables.length > 0 + uncollectable = @state.uncollectables[0] + uncollectableMessage = `
A charge for your music lesson with {uncollectable.teacher.name} failed. Please update your credit card information immediately so that we can pay the instructor. If you have called your credit card provider and believe there should be no problem with your card, please email us at support@jamkazam.com so that we can figure out what's gone wrong. Thank you!
` + + if @state.user?['has_stored_credit_card?'] && @state.uncollectables.length == 0 + if @state.userWantsUpdateCC + header = 'Please update your billing address and payment information below.' + updateCardAction = `NEVERMIND` + actions = `
+ BACK + {updateCardAction} + SUBMIT CARD INFORMATION +
` + else + header = 'You have already entered a credit card in JamKazam.' + updateCardAction = `I'D LIKE TO UPDATE MY PAYMENT INFO` + actions = `
+ BACK + {updateCardAction} +
` + else + header = 'Please enter your billing address and payment information below.' + actions = `
+ BACKSUBMIT CARD INFORMATION +
` + if @state.shouldShowName && @state.user?.name? + username = @state.user?.name + nameField = + `
+ + +
` + + `
+
+ {uncollectableMessage} +
{header}
+
+ {nameField} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ {actions} +
+
+
` paymentsToJamKazam: () -> rows = [] + uncollectables = [] + for uncollectable in @state.uncollectables + date = context.JK.formatDate(uncollectable.last_billed_at_date, true) + paymentMethod = 'Credit Card' + amt = uncollectable.expected_price_in_cents + displayAmount = ' $' + (amt/100).toFixed(2) + + if uncollectable['is_card_declined?'] + reason = 'card declined' + else if uncollectable['is_card_expired?'] + reason = 'card expired' + else + reason = 'charge fail' + + row = + ` + {date} + {paymentMethod} + {uncollectable.description} + {reason} + {displayAmount} + ` + uncollectables.push(row) + + if uncollectables.length > 0 + uncollectableTable = ` +
+
You have unpaid lessons, which are listed immediately below. Click here to update your credit card info.
+
Unpaid Lessons
+ + + + + + + + + + + + {uncollectables} + +
CHARGED ATMETHODDESCRIPTIONREASONAMOUNT
+
Payments
+
` for paymentHistory in @getCurrentList() paymentMethod = 'Credit Card' if paymentHistory.sale? @@ -317,6 +497,7 @@ profileUtils = context.JK.ProfileUtils rows.push(row) `
+ {uncollectableTable} @@ -342,13 +523,14 @@ profileUtils = context.JK.ProfileUtils selectionMade: (selection, e) -> e.preventDefault() + @getUncollectables() @setState({selected: selection}) activeTile: (selected = this.state.selected) -> if selected? selected else - @tiles()[0] + @tiles()[-1] createTileLink: (i, tile) -> if this.state.selected? @@ -455,4 +637,153 @@ profileUtils = context.JK.ProfileUtils handled = true @setState({updateErrors: errors}) + onSubmit: (e) -> + @resetErrors() + + e.preventDefault() + + if !window.Stripe? + @app.layout.notify({ + title: 'Payment System Not Loaded', + text: "Please refresh this page and try to enter your info again. Sorry for the inconvenience!" + }) + else + ccNumber = @root.find('input.card-number').val() + expiration = @root.find('input.expiration').val() + cvv = @root.find('input.cvv').val() + inUS = @root.find('input.billing-address-in-us').is(':checked') + zip = @root.find('input.zip').val() + + error = false + + if @state.shouldShowName + name = @root.find('#set-user-on-card').val() + + if name.indexOf('Anonymous') > -1 + @setState({nameError: true}) + error = true + + if !$.payment.validateCardNumber(ccNumber) + @setState({ccError: true}) + error = true + + bits = expiration.split('/') + + if bits.length == 2 + month = bits[0].trim(); + year = bits[1].trim() + + month = new Number(month) + year = new Number(year) + + if year < 2000 + year += 2000 + + if !$.payment.validateCardExpiry(month, year) + @setState({expiryError: true}) + error = true + else + @setState({expiryError: true}) + error = true + + + cardType = $.payment.cardType(ccNumber) + + if !$.payment.validateCardCVC(cvv, cardType) + @setState({cvvError: true}) + error = true + + if inUS && (!zip? || zip == '') + @setState({zipCodeError: true}) + + if error + return + + data = { + number: ccNumber, + cvc: cvv, + exp_month: month, + exp_year: year, + } + + @setState({updating: true}) + + window.Stripe.card.createToken(data, (status, response) => (@stripeResponseHandler(status, response))); + + stripeResponseHandler: (status, response) -> + console.log("stripe response", JSON.stringify(response)) + + + if response.error + @setState({updating: false}) + if response.error.code == "invalid_number" + @setState({ccError: true, cvvError: null, expiryError: null}) + else if response.error.code == "invalid_cvc" + @setState({ccError: null, cvvError: true, expiryError: null}) + else if response.error.code == "invalid_expiry_year" || response.error.code == "invalid_expiry_month" + @setState({ccError: null, cvvError: null, expiryError: true}) + + #@setState({userWantsUpdateCC: false}) + #window.UserActions.refresh() + @storeCC(response.id) + + storeCC: (token) -> + if this.state.billingInUS + zip = @root.find('input.zip').val() + + data = { + token: token, + zip: zip, + test_drive: false, + normal: false + } + + if @state.shouldShowName + data.name = @root.find('#set-user-on-card').val() + + @setState({updating: true}) + rest.submitStripe(data).done((response) => @stripeSubmitted(response)).fail((jqXHR) => @stripeSubmitFailure(jqXHR)) + + stripeSubmitted: (response) -> + @setState({updating: false}) + + logger.debug("stripe submitted: " + JSON.stringify(response)) + + @setState({userWantsUpdateCC: false}) + + #if @state.shouldShowName + window.UserActions.refresh() + + if response.uncollectables + context.JK.Banner.showAlert('Credit Card Updated', 'Than you. Your credit card info has been updated.

We will try to bill any unpaid lessons within the next hour, and an email will be sent at that time.') + else + @app.layout.notify({title: 'Credit Card Updated', text: 'Your credit card info has been updated.'}) + + + stripeSubmitFailure: (jqXHR) -> + @setState({updating: false}) + handled = false + if jqXHR.status == 422 + errors = JSON.parse(jqXHR.responseText) + if errors.errors.name? + @setState({name: errors.errors.name[0]}) + handled = true + else if errors.errors.user? + @app.layout.notify({title: "Can't Update Credit Card", text: "You " + errors.errors.user[0] + '.' }) + handled = true + + if !handled + @app.notifyServerError(jqXHR, 'Credit Card Not Stored') + + onUnlockPaymentInfo: (e) -> + e.preventDefault() + @setState({userWantsUpdateCC: true}) + + onLockPaymentInfo: (e) -> + e.preventDefault() + @setState({userWantsUpdateCC: false}) + + reuseStoredCard: () -> + !@state.userWantsUpdateCC && @state.user?['has_stored_credit_card?'] && @state.uncollectables.length == 0 + }) \ No newline at end of file diff --git a/web/app/assets/javascripts/react-components/LessonPayment.js.jsx.coffee b/web/app/assets/javascripts/react-components/LessonPayment.js.jsx.coffee index 7de3cabb9..384ce3b9e 100644 --- a/web/app/assets/javascripts/react-components/LessonPayment.js.jsx.coffee +++ b/web/app/assets/javascripts/react-components/LessonPayment.js.jsx.coffee @@ -336,6 +336,7 @@ UserStore = context.UserStore window.location = "/client#/teachers/search" stripeSubmitFailure: (jqXHR) -> + @setState({updating: false}) handled = false if jqXHR.status == 422 errors = JSON.parse(jqXHR.responseText) @@ -369,6 +370,7 @@ UserStore = context.UserStore return booked_price.toFixed(2) else return '??' + render: () -> disabled = @state.updating || @reuseStoredCard() diff --git a/web/app/assets/javascripts/utils.js b/web/app/assets/javascripts/utils.js index ec6bb6cf6..1fc350fbb 100644 --- a/web/app/assets/javascripts/utils.js +++ b/web/app/assets/javascripts/utils.js @@ -764,6 +764,10 @@ // returns Fri May 20, 2013 context.JK.formatDate = function (dateString, suppressDay) { + if (!dateString) { + return 'N/A' + } + var date = new Date(dateString); return (suppressDay ? '' : (days[date.getDay()] + ' ')) + months[date.getMonth()] + ' ' + context.JK.padString(date.getDate(), 2) + ', ' + date.getFullYear(); } diff --git a/web/app/assets/stylesheets/client/accountPaymentHistory.css.scss b/web/app/assets/stylesheets/client/accountPaymentHistory.css.scss index 9c3931913..496947148 100644 --- a/web/app/assets/stylesheets/client/accountPaymentHistory.css.scss +++ b/web/app/assets/stylesheets/client/accountPaymentHistory.css.scss @@ -8,6 +8,17 @@ overflow:auto; } + .payment-table.unpaid { + margin-bottom:30px; + } + .table-header { + margin:0 0 10px; + color:white; + font-weight:bold; + &.second { + margin:20px 0 10px; + } + } .content-body { padding-top:29px; height:100%; @@ -115,6 +126,52 @@ @include border-box_sizing; width: 100%; } + label { + display:inline-block; + } + select { + display:inline-block; + } + + form { + &.stored { + display:none; + } + } + + input { + display:inline-block; + width: calc(100% - 150px); + @include border_box_sizing; + max-width:200px; + } + .field { + position:relative; + display:block; + margin-top:15px; + margin-bottom:25px; + + label { + width:150px; + } + } + + .uncollectable-msg { + background-color:black; + color:white; + padding:20px; + margin:20px 0; + } + + .paymethod-header { + margin:20px 0; + } + .column-left { + margin:20px 0 20px 21px; + } + .actions { + margin-left:-5px; + } } \ No newline at end of file diff --git a/web/app/controllers/api_lesson_sessions_controller.rb b/web/app/controllers/api_lesson_sessions_controller.rb index 6bc84e732..b2c19bf01 100644 --- a/web/app/controllers/api_lesson_sessions_controller.rb +++ b/web/app/controllers/api_lesson_sessions_controller.rb @@ -1,7 +1,7 @@ class ApiLessonSessionsController < ApiController before_filter :api_signed_in_user - before_filter :lookup_lesson, except: [:index] + before_filter :lookup_lesson, except: [:index, :uncollectable] before_filter :is_teacher, only: [:accept] before_filter :is_student, only: [] respond_to :json @@ -111,6 +111,9 @@ class ApiLessonSessionsController < ApiController render :json => {}, :status => 200 end + def uncollectable + @lesson_payment_charges = current_user.uncollectables + end private diff --git a/web/app/controllers/api_stripe_controller.rb b/web/app/controllers/api_stripe_controller.rb index 72eec53c6..c3c9db0e9 100644 --- a/web/app/controllers/api_stripe_controller.rb +++ b/web/app/controllers/api_stripe_controller.rb @@ -15,6 +15,7 @@ class ApiStripeController < ApiController @test_drive = data[:test_drive] @normal = data[:normal] @lesson_package_type = data[:lesson_package_type] + @uncollectables = data[:uncollectables] end end diff --git a/web/app/views/api_lesson_sessions/uncollectable.rabl b/web/app/views/api_lesson_sessions/uncollectable.rabl new file mode 100644 index 000000000..92c7cc395 --- /dev/null +++ b/web/app/views/api_lesson_sessions/uncollectable.rabl @@ -0,0 +1,7 @@ +object @lesson_payment_charges + +attributes :id, :description, :expected_price_in_cents, :is_card_declined?, :is_card_expired?, :last_billed_at_date + +child(:teacher => :teacher) { + attributes :name +} \ No newline at end of file diff --git a/web/app/views/api_stripe/store.rabl b/web/app/views/api_stripe/store.rabl index 27e32132a..82b7d0726 100644 --- a/web/app/views/api_stripe/store.rabl +++ b/web/app/views/api_stripe/store.rabl @@ -26,4 +26,9 @@ if @lesson_package_type end end +if @uncollectables + node :uncollectables do |lesson| + true + end +end diff --git a/web/config/routes.rb b/web/config/routes.rb index 9d5c3ac0b..ce91aecbe 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -691,6 +691,7 @@ SampleApp::Application.routes.draw do match '/lesson_bookings/unprocessed' => 'api_lesson_bookings#unprocessed', :via => :get match '/lesson_bookings/unprocessed_or_intent' => 'api_lesson_bookings#unprocessed_or_intent', :via => :get + match '/lesson_sessions/uncollectable' => 'api_lesson_sessions#uncollectable', :via => :get match '/lesson_sessions/:id' => 'api_lesson_sessions#show', :via => :get match '/lesson_sessions/:id/update_unread_messages' => 'api_lesson_sessions#update_unread_messages', :via => :post match '/lesson_sessions/:id/start_time' => 'api_lesson_sessions#start_time', :via => :post diff --git a/web/spec/factories.rb b/web/spec/factories.rb index 3c7b09c4a..e57b7f9de 100644 --- a/web/spec/factories.rb +++ b/web/spec/factories.rb @@ -980,8 +980,13 @@ FactoryGirl.define do factory :teacher_payment_charge, parent: :charge, class: 'JamRuby::TeacherPaymentCharge' do type 'JamRuby::TeacherPaymentCharge' + association :user, factory: :user end + factory :lesson_payment_charge, parent: :charge, class: 'JamRuby::LessonPaymentCharge' do + type 'JamRuby::LessonPaymentCharge' + association :user, factory: :user + end factory :teacher_payment, class: 'JamRuby::TeacherPayment' do association :teacher, factory: :teacher_user diff --git a/web/spec/features/account_payment_spec.rb b/web/spec/features/account_payment_spec.rb new file mode 100644 index 000000000..e8e79a12c --- /dev/null +++ b/web/spec/features/account_payment_spec.rb @@ -0,0 +1,108 @@ +require 'spec_helper' + +describe "Account Payment", :js => true, :type => :feature, :capybara_feature => true do + + subject { page } + + let(:user) { FactoryGirl.create(:user, traditional_band: true,paid_sessions: true, paid_sessions_hourly_rate: 1, paid_sessions_daily_rate:1 ) } + let(:jam_track) {FactoryGirl.create(:jam_track)} + + + before(:each) do + JamTrackRight.delete_all + JamTrack.delete_all + AffiliateQuarterlyPayment.delete_all + AffiliateMonthlyPayment.delete_all + AffiliateTrafficTotal.delete_all + UserMailer.deliveries.clear + emulate_client + sign_in_poltergeist user + visit "/client#/account" + + find('div.account-mid.identity') + end + + describe "payment history" do + it "show 1 sale" do + + sale = Sale.create_jam_track_sale(user) + shopping_cart = ShoppingCart.create(user, jam_track) + sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, 'some_adjustment_uuid', nil) + + visit "/client#/account" + + find('.account-mid.payments', text: 'You have made 1 purchase.') + + find("#account-payment-history-link").trigger(:click) + find('.account-header', text: 'payment history:') + find('table tr td', text: '$0.00') # 1st purchase is free + + find('.profile-tile.student a', text: 'payment method').trigger(:click) + + + fill_in 'card-number', with: '4111111111111111' + fill_in 'expiration', with: '11/2016' + fill_in 'cvv', with: '111' + fill_in 'zip', with: '78759' + + find('.purchase-btn').trigger(:click) + + find('a.update-btn', text: "I'D LIKE TO UPDATE MY PAYMENT INFO").trigger(:click) + + user.reload + user.stripe_customer_id.should_not be_nil + user.stripe_token.should_not be_nil + original_token = user.stripe_token + + fill_in 'card-number', with: '4111111111111111' + fill_in 'expiration', with: '11/2016' + fill_in 'cvv', with: '111' + fill_in 'zip', with: '78759' + + find('.purchase-btn').trigger(:click) + + find('a.update-btn', text: "I'D LIKE TO UPDATE MY PAYMENT INFO").trigger(:click) + + user.reload + original_token.should_not eql user.stripe_token + end + end + + it "handles unpaid lessons" do + teacher = FactoryGirl.create(:teacher_user) + lesson_session = normal_lesson(user, teacher) + lesson_session.lesson_payment_charge.user.should eql user + lesson_session.lesson_payment_charge.billing_attempts = 1 + lesson_session.lesson_payment_charge.save! + uncollectables = user.uncollectables + uncollectables.count.should eql 1 + + visit "/client#/account" + + find('.account-mid.payments', text: 'You have made no purchases.') + sleep 2 + find("#account-payment-history-link").trigger(:click) + find('.account-header', text: 'payment history:') + + find('.uncollectable-msg', text: 'You have unpaid lessons') + find('.uncollectable-msg a').trigger(:click) + + + fill_in 'card-number', with: '4111111111111111' + fill_in 'expiration', with: '11/2016' + fill_in 'cvv', with: '111' + fill_in 'zip', with: '78759' + + find('.purchase-btn').trigger(:click) + + find('#banner .dialog-inner', text: 'Your credit card info has been updated') + + # dismiss banner + find('a.button-orange', text:'CLOSE').trigger(:click) + + user.reload + + user.stripe_customer_id.should_not be_nil + user.stripe_token.should_not be_nil + end +end diff --git a/web/spec/features/account_spec.rb b/web/spec/features/account_spec.rb index d32ecb80b..1e244e1a6 100644 --- a/web/spec/features/account_spec.rb +++ b/web/spec/features/account_spec.rb @@ -148,27 +148,6 @@ describe "Account", :js => true, :type => :feature, :capybara_feature => true do end end - describe "payment history" do - - it "show 1 sale" do - - sale = Sale.create_jam_track_sale(user) - shopping_cart = ShoppingCart.create(user, jam_track) - sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, 'some_adjustment_uuid', nil) - - visit "/client#/account" - - find('.account-mid.payments', text: 'You have made 1 purchase.') - - find("#account-payment-history-link").trigger(:click) - find('h2', text: 'payment history:') - find('table tr td', text: '$0.00') # 1st purchase is free - - end - - - end - describe "sessions" do before(:each) do diff --git a/web/spec/support/lessons.rb b/web/spec/support/lessons.rb index 375b93b40..40ae0e8e2 100644 --- a/web/spec/support/lessons.rb +++ b/web/spec/support/lessons.rb @@ -115,3 +115,72 @@ def testdrive_lesson(user, teacher, slots = nil) lesson end + + + +def normal_lesson(user, teacher, slots = nil) + + if slots.nil? + slots = [] + slots << FactoryGirl.build(:lesson_booking_slot_single) + slots << FactoryGirl.build(:lesson_booking_slot_single) + end + + if user.stored_credit_card == false + user.stored_credit_card = true + user.save! + end + + booking = LessonBooking.book_normal(user, teacher, slots, "Hey I've heard of you before.", false, LessonBooking::PAYMENT_STYLE_SINGLE, 60) + # puts "NORMAL BOOKING #{booking.errors.inspect}" + booking.errors.any?.should be_false + lesson = booking.lesson_sessions[0] + booking.card_presumed_ok.should be_true + + #if user.most_recent_test_drive_purchase.nil? + # LessonPackagePurchase.create(user, booking, LessonPackageType.test_drive_4) + #end + + lesson.accept({message: 'Yeah I got this', slot: slots[0]}) + lesson.errors.any?.should be_false + lesson.reload + lesson.slot.should eql slots[0] + lesson.status.should eql LessonSession::STATUS_APPROVED + lesson.music_session.should_not be_nil + + lesson +end + + +def monthly_lesson(user, teacher, slots = nil) + + if slots.nil? + slots = [] + slots << FactoryGirl.build(:lesson_booking_slot_recurring) + slots << FactoryGirl.build(:lesson_booking_slot_recurring) + end + + if user.stored_credit_card == false + user.stored_credit_card = true + user.save! + end + + booking = LessonBooking.book_normal(user, teacher, slots, "Hey I've heard of you before.", true, LessonBooking::PAYMENT_STYLE_MONTHLY, 60) + # puts "NORMAL BOOKING #{booking.errors.inspect}" + booking.errors.any?.should be_false + lesson = booking.lesson_sessions[0] + booking.card_presumed_ok.should be_true + + #if user.most_recent_test_drive_purchase.nil? + # LessonPackagePurchase.create(user, booking, LessonPackageType.test_drive_4) + #end + + lesson.accept({message: 'Yeah I got this', slot: slots[0]}) + lesson.errors.any?.should be_false + lesson.reload + lesson.slot.should eql slots[0] + lesson.status.should eql LessonSession::STATUS_APPROVED + lesson.music_session.should_not be_nil + + lesson +end