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
| CHARGED AT | +METHOD | +DESCRIPTION | +REASON | +AMOUNT | +
|---|