# represenst the type of lesson package module JamRuby class LessonBooking < ActiveRecord::Base include HtmlSanitize html_sanitize strict: [:description, :cancel_message] include ActiveModel::Dirty @@log = Logging.logger[LessonBooking] attr_accessor :accepting, :countering, :canceling, :countered_slot, :countered_lesson STATUS_REQUESTED = 'requested' STATUS_CANCELED = 'canceled' STATUS_APPROVED = 'approved' STATUS_SUSPENDED = 'suspended' STATUS_COUNTERED = 'countered' STATUS_TYPES = [STATUS_REQUESTED, STATUS_CANCELED, STATUS_APPROVED, STATUS_SUSPENDED, STATUS_COUNTERED] LESSON_TYPE_FREE = 'single-free' LESSON_TYPE_TEST_DRIVE = 'test-drive' LESSON_TYPE_PAID = 'paid' LESSON_TYPES = [LESSON_TYPE_FREE, LESSON_TYPE_TEST_DRIVE, LESSON_TYPE_PAID] PAYMENT_STYLE_ELSEWHERE = 'elsewhere' PAYMENT_STYLE_SINGLE = 'single' PAYMENT_STYLE_WEEKLY = 'weekly' PAYMENT_STYLE_MONTHLY = 'monthly' PAYMENT_STYLES = [PAYMENT_STYLE_ELSEWHERE, PAYMENT_STYLE_SINGLE, PAYMENT_STYLE_WEEKLY, PAYMENT_STYLE_MONTHLY] belongs_to :user, class_name: "JamRuby::User" belongs_to :teacher, class_name: "JamRuby::User" belongs_to :accepter, class_name: "JamRuby::User" belongs_to :canceler, class_name: "JamRuby::User" belongs_to :default_slot, class_name: "JamRuby::LessonBookingSlot", foreign_key: :default_slot_id, inverse_of: :defaulted_booking, :dependent => :destroy belongs_to :counter_slot, class_name: "JamRuby::LessonBookingSlot", foreign_key: :counter_slot_id, inverse_of: :countered_booking, :dependent => :destroy belongs_to :school, class_name: "JamRuby::School" has_many :lesson_booking_slots, class_name: "JamRuby::LessonBookingSlot", :dependent => :destroy has_many :lesson_sessions, class_name: "JamRuby::LessonSession", :dependent => :destroy has_many :lesson_package_purchases, class_name: "JamRuby::LessonPackagePurchase", :dependent => :destroy validates :user, presence: true validates :teacher, presence: true validates :lesson_type, inclusion: {in: LESSON_TYPES} validates :status, presence: true, inclusion: {in: STATUS_TYPES} validates :recurring, inclusion: {in: [true, false]} validates :sent_notices, inclusion: {in: [true, false]} validates :card_presumed_ok, inclusion: {in: [true, false]} validates :active, inclusion: {in: [true, false]} validates :lesson_length, inclusion: {in: [30, 45, 60, 90, 120]} validates :payment_style, inclusion: {in: PAYMENT_STYLES} validates :booked_price, presence: true validates :description, no_profanity: true, length: {minimum: 10, maximum: 20000} validate :validate_user, on: :create validate :validate_recurring validate :validate_lesson_booking_slots validate :validate_lesson_length validate :validate_payment_style validate :validate_uncollectables, on: :create validate :validate_accepted, :if => :accepting validate :validate_canceled, :if => :canceling before_save :before_save before_validation :before_validation after_create :after_create around_save :around_update scope :test_drive, -> { where(lesson_type: LESSON_TYPE_TEST_DRIVE) } scope :active, -> { where(active: true) } scope :approved, -> { where(status: STATUS_APPROVED) } scope :requested, -> { where(status: STATUS_REQUESTED) } scope :canceled, -> { where(status: STATUS_CANCELED) } scope :suspended, -> { where(status: STATUS_SUSPENDED) } scope :engaged, -> { where("status = '#{STATUS_APPROVED}' OR status = '#{STATUS_REQUESTED}' OR status = '#{STATUS_SUSPENDED}'") } def before_validation if self.booked_price.nil? self.booked_price = compute_price end end def after_create if card_presumed_ok && !sent_notices send_notices end end def before_save automatically_default_slot end def around_update @default_slot_did_change = self.default_slot_id_changed? yield sync_lessons sync_remaining_test_drives @default_slot_did_change = nil @accepting = nil @countering = nil end # here for shopping_cart def product_info {price: booked_price, real_price: booked_price, total_price: booked_price} end # here for shopping_cart def price booked_price end def alt_slot found = nil lesson_booking_slots.each do |slot| if slot.id != default_slot.id found = slot break end end found end def student user end def next_lesson if recurring session = lesson_sessions.joins(:music_session).where("scheduled_start is not null").where("scheduled_start > ?", Time.now).order(:created_at).first if session.nil? session = lesson_sessions[0] end LessonSession.find(session.id) if session else lesson_sessions[0] end end def accept(lesson_session, slot, accepter) if !is_active? self.accepting = true end self.active = true self.status = STATUS_APPROVED self.counter_slot = nil self.default_slot = slot self.accepter = accepter success = self.save if !success puts "unable to accept lesson booking #{errors.inspect}" end success end def counter(lesson_session, proposer, slot) self.countering = true self.lesson_booking_slots << slot self.counter_slot = slot #self.status = STATUS_COUNTERED self.save end def automatically_default_slot if is_requested? if lesson_booking_slots.length > 0 self.default_slot = lesson_booking_slots[0] end end end def sync_remaining_test_drives if is_test_drive? || is_single_free? if card_presumed_ok && !user_decremented self.user_decremented = true self.save(validate: false) if is_single_free? user.remaining_free_lessons = user.remaining_free_lessons - 1 elsif is_test_drive? user.remaining_test_drives = user.remaining_test_drives - 1 end user.save(validate: false) end end end def create_minimum_booking_time # trying to be too smart #(Time.now + APP_CONFIG.minimum_lesson_booking_hrs * 60*60) Time.now end def sync_lessons if is_canceled? # don't create new sessions if cancelled return end if @default_slot_did_change end # Here we go; let's create a lesson(s) as needed # we need to make lessons into the future a bit, to give time for everyone involved minimum_start_time = create_minimum_booking_time # get all sessions that are already scheduled for this booking ahead of the minimum time sessions = MusicSession.joins(:lesson_session).where("lesson_sessions.lesson_booking_id = ?", id).where("scheduled_start is not null").where("scheduled_start > ?", minimum_start_time).order(:created_at) if @default_slot_did_change # # adjust all session times offset = 0 sessions.each_with_index do |item, i| item.lesson_session.slot = default_slot result = item.lesson_session.update_next_available_time(offset) if result offset = result offset += 1 end end end needed_sessions = determine_needed_sessions(sessions) # if the latest scheduled session is after the minimum start time, then bump up minimum start time last_session = sessions.last last_session.reload if last_session # because of @default_slot_did_change logic above, this can be necessary if last_session && last_session.scheduled_start && last_session.scheduled_start > minimum_start_time minimum_start_time = last_session.scheduled_start end times = default_slot.scheduled_times(needed_sessions, minimum_start_time) scheduled_lessons(times) end # sensitive to current time def predicted_times_for_month(year, month) first_day = Date.new(year, month, 1) last_day = Date.new(year, month, -1) sessions = MusicSession.joins(:lesson_session).where("lesson_sessions.lesson_booking_id = ?", id).where("scheduled_start >= ?", first_day).where("scheduled_start <= ?", last_day).order(:created_at) times = [] sessions.each do |session| times << session.scheduled_start end last_session = sessions.last start_day = first_day if last_session start_day = last_session.scheduled_start.to_date + 1 end # now flesh out the rest of the month with predicted times more_times = default_slot.scheduled_times(5, start_day) more_times.each do |time| if time.to_date >= first_day && time.to_date <= last_day times << time end end { times: times, session: sessions.first } end def determine_needed_sessions(sessions) needed_sessions = 0 if is_requested? # in the case of a requested booking (not approved) only make one, even if it's recurring. This is for UI considerations if sessions.count == 0 needed_sessions = 1 end elsif is_active? expected_num_sessions = recurring ? 2 : 1 needed_sessions = expected_num_sessions - sessions.count end needed_sessions end def scheduled_lessons(times) times.each do |time| lesson_session = LessonSession.create(self) if lesson_session.errors.any? puts "JamClass lesson session creation errors #{lesson_session.errors.inspect}" @@log.error("JamClass lesson session creation errors #{lesson_session.errors.inspect}") raise ActiveRecord::Rollback end ms_tz = ActiveSupport::TimeZone.new(default_slot.timezone) ms_tz = "#{ms_tz.name},#{default_slot.timezone}" rsvps = [{instrument_id: 'other', proficiency_level: 0, approve: true}] music_session = MusicSession.create(student, { name: "#{display_type2} JamClass taught by #{teacher.name}", description: "This is a #{lesson_length}-minute #{display_type2} lesson with #{teacher.name}.", musician_access: false, fan_access: false, genres: ['other'], approval_required: false, fan_chat: false, legal_policy: "standard", language: 'eng', duration: lesson_length, recurring_mode: false, timezone: ms_tz, create_type: MusicSession::CREATE_TYPE_LESSON, is_unstructured_rsvp: true, scheduled_start: time, invitations: [teacher.id], lesson_session: lesson_session, rsvp_slots: rsvps }) if music_session.errors.any? puts "JamClass lesson scheduling errors #{music_session.errors.inspect}" @@log.error("JamClass lesson scheduling errors #{music_session.errors.inspect}") raise ActiveRecord::Rollback end if lesson_session.is_active? # send out email to student to act as something they can add to their calendar Notification.send_student_jamclass_invitation(music_session, student) end end end def is_weekly_payment? payment_style == PAYMENT_STYLE_WEEKLY end def is_monthly_payment? payment_style == PAYMENT_STYLE_MONTHLY end def requires_per_session_billing? is_normal? && !is_monthly_payment? end def requires_teacher_distribution?(target) if target.is_a?(JamRuby::LessonSession) is_test_drive? || (is_normal? && !is_monthly_payment?) elsif target.is_a?(JamRuby::LessonPackagePurchase) is_monthly_payment? else raise "unable to determine object type of #{target}" end end def is_requested? status == STATUS_REQUESTED end def is_canceled? status == STATUS_CANCELED end def is_approved? status == STATUS_APPROVED end def is_suspended? status == STATUS_SUSPENDED end def is_active? active end def validate_accepted # accept is multipe purpose; either accept the initial request, or a counter slot if self.status_was != STATUS_REQUESTED && counter_slot.nil? # && self.status_was != STATUS_COUNTERED self.errors.add(:status, "This lesson is already #{self.status}.") end self.accepting = false end def validate_canceled if !is_canceled? self.errors.add(:status, "This session is already #{self.status}.") end self.canceling = false end def send_notices UserMailer.student_lesson_request(self).deliver UserMailer.teacher_lesson_request(self).deliver Notification.send_lesson_message('requested', lesson_sessions[0], false) # TODO: this isn't quite an 'accept' self.sent_notices = true self.save end def resolved_test_drive_package result = nil purchase = student.most_recent_test_drive_purchase if purchase # for lessons already packaged result = purchase.lesson_package_type else # for unbooked lessons result = student.desired_package end if result.nil? result = LessonPackageType.test_drive_4 end result end def lesson_package_type if is_single_free? LessonPackageType.single_free elsif is_test_drive? resolved_test_drive_package elsif is_normal? LessonPackageType.single end end def display_type2 if is_single_free? "Free" elsif is_test_drive? "TestDrive" elsif is_normal? "Single" end end def display_type if is_single_free? "Free" elsif is_test_drive? "TestDrive" elsif is_normal? if recurring "recurring" else "single" end end end # determine the price of this booking based on what the user wants, and the teacher's pricing def compute_price if is_single_free? 0 elsif is_test_drive? resolved_test_drive_package.price elsif is_normal? teacher.teacher.booking_price(lesson_length, payment_style != PAYMENT_STYLE_MONTHLY) end end def distribution_price_in_cents(target) if is_single_free? 0 elsif is_test_drive? 10 * 100 elsif is_normal? if is_monthly_payment? raise "not a LessonPackagePurchase: #{target.inspect}" if !target.is_a?(LessonPackagePurchase) today = Date.today start_date = Date.new(target.year, target.month, 1) if today.year == target.year && today.month == target.month # we are in the month being billed. we should set the start date based on today start_date = today end LessonSessionMonthlyPrice.price(self, start_date) * 100 else booked_price * 100 end end end def is_single_free? lesson_type == LESSON_TYPE_FREE end def is_test_drive? lesson_type == LESSON_TYPE_TEST_DRIVE end def is_normal? lesson_type == LESSON_TYPE_PAID end def dayWeekDesc(slot = default_slot) day = case slot.day_of_week when 0 then "Sunday" when 1 then "Monday" when 2 then "Tuesday" when 3 then "Wednesday" when 4 then "Thursday" when 5 then "Friday" when 6 then "Saturday" end if slot.hour > 11 hour = slot.hour - 12 if hour == 0 hour = 12 end am_pm = 'pm' else hour = slot.hour if hour == 0 hour = 12 end am_pm = 'am' end "#{day} at #{hour}:#{slot.minute}#{am_pm}" end def approved_before? !self.accepter_id.nil? end def cancel(canceler, other, message) self.canceling = true self.active = false self.status = STATUS_CANCELED self.cancel_message = message self.canceler = canceler success = save if success lesson_sessions.past_cancel_window.each do |lesson_session| lesson_session = LessonSession.find(lesson_session.id) # because .upcoming creates ReadOnly records lesson_session.cancel_lesson(canceler, message) if !lesson_session.save return lesson_session end end if approved_before? # just tell both people it's cancelled, to act as confirmation Notification.send_lesson_message('canceled', next_lesson, false) Notification.send_lesson_message('canceled', next_lesson, true) UserMailer.student_lesson_booking_canceled(self, message).deliver UserMailer.teacher_lesson_booking_canceled(self, message).deliver purpose = "Lesson Canceled" else if canceler == student # if it's the first time acceptance student canceling, we call it a 'cancel' Notification.send_lesson_message('canceled', next_lesson, false) UserMailer.teacher_lesson_booking_canceled(self, message).deliver purpose = "Lesson Canceled" else # if it's the first time acceptance teacher, it was declined UserMailer.student_lesson_booking_declined(self, message).deliver Notification.send_lesson_message('declined', next_lesson, true) purpose = "Lesson Declined" end end message = '' if message.nil? msg = ChatMessage.create(canceler, nil, message, ChatMessage::CHANNEL_LESSON, nil, other, next_lesson, purpose) end self end def card_approved self.card_presumed_ok = true if self.save && !sent_notices send_notices end end def validate_user if card_presumed_ok && is_single_free? if !user.has_free_lessons? errors.add(:user, 'have no remaining free lessons') end #if !user.has_stored_credit_card? # errors.add(:user, 'has no credit card stored') #end elsif is_test_drive? if user.has_requested_test_drive?(teacher) && !user.admin 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") end elsif is_normal? #if !user.has_stored_credit_card? # errors.add(:user, 'has no credit card stored') #end end end def validate_teacher # shouldn't we check if the teacher already has a booking in this time slot, or at least warn the user end def validate_recurring if is_single_free? || is_test_drive? if recurring errors.add(:recurring, "can not be true for this type of lesson") end end false end def validate_lesson_booking_slots if lesson_booking_slots.length == 0 || lesson_booking_slots.length == 1 errors.add(:lesson_booking_slots, "must have two times specified") end end def validate_lesson_length if is_single_free? || is_test_drive? if lesson_length != 30 errors.add(:lesson_length, "must be 30 minutes") end end end def validate_payment_style if is_normal? if payment_style.nil? errors.add(:payment_style, "can't be blank") end end end def validate_uncollectables if user.uncollectables.count > 0 errors.add(:user, 'have unpaid lessons.') end end def school_owned? !!school 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) end def self.book_test_drive(user, teacher, lesson_booking_slots, description) self.book(user, teacher, LessonBooking::LESSON_TYPE_TEST_DRIVE, lesson_booking_slots, false, 30, PAYMENT_STYLE_ELSEWHERE, description) end def self.book_normal(user, teacher, lesson_booking_slots, description, recurring, payment_style, lesson_length) self.book(user, teacher, LessonBooking::LESSON_TYPE_PAID, lesson_booking_slots, recurring, lesson_length, payment_style, description) end def self.book(user, teacher, lesson_type, lesson_booking_slots, recurring, lesson_length, payment_style, description) lesson_booking = nil LessonBooking.transaction do lesson_booking = LessonBooking.new lesson_booking.user = user lesson_booking.card_presumed_ok = user.has_stored_credit_card? lesson_booking.sent_notices = false lesson_booking.teacher = teacher lesson_booking.lesson_type = lesson_type lesson_booking.recurring = recurring lesson_booking.lesson_length = lesson_length lesson_booking.payment_style = payment_style lesson_booking.description = description lesson_booking.status = STATUS_REQUESTED if lesson_booking.teacher && lesson_booking.teacher.teacher.school lesson_booking.school = school end # two-way association slots, for before_validation loic in slot to work lesson_booking.lesson_booking_slots = lesson_booking_slots lesson_booking_slots.each do |slot| slot.lesson_booking = lesson_booking slot.message = description end if lesson_booking_slots if lesson_booking.save description = '' if description.nil? msg = ChatMessage.create(user, lesson_booking.lesson_sessions[0], description, ChatMessage::CHANNEL_LESSON, nil, teacher, lesson_booking.lesson_sessions[0], 'Lesson Requested') end end lesson_booking end def self.unprocessed(current_user) LessonBooking.where(user_id: current_user.id).where(card_presumed_ok: false) end def self.requested(current_user) LessonBooking.where(user_id: current_user.id).where(status: STATUS_REQUESTED) end def self.find_bookings_needing_sessions(minimum_start_time) MusicSession.select([:lesson_booking_id]).joins(:lesson_session => :lesson_booking).where("lesson_bookings.active = true").where('lesson_bookings.recurring = true').where("scheduled_start is not null").where("scheduled_start > ?", minimum_start_time).group(:lesson_booking_id).having('count(lesson_booking_id) < 2') end # check for any recurring sessions where there are not at least 2 sessions into the future. If not, we need to make sure they get made def self.hourly_check schedule_upcoming_lessons bill_monthlies end def self.bill_monthlies now = Time.now billable_monthlies(now).each do |lesson_booking| lesson_booking.bill_monthly(now) end today = now.to_date seven_days_in_future = today + 7 is_different_month = seven_days_in_future.month != today.month if is_different_month next_month = seven_days_in_future.to_time billable_monthlies(next_month).each do |lesson_booking| lesson_booking.bill_monthly(next_month) end end end def self.billable_monthlies(now) current_month_first_day = Date.new(now.year, now.month, 1) current_month_last_day = Date.new(now.year, now.month, -1) #next_month_last_day = now.month == 12 ? Date.new(now.year + 1, 1, -1) : Date.new(now.year, now.month + 1, -1) LessonBooking .joins(:lesson_sessions => :music_session) .joins("LEFT JOIN lesson_package_purchases ON (lesson_package_purchases.lesson_booking_id = lesson_bookings.id AND (lesson_package_purchases.year = #{current_month_first_day.year} AND lesson_package_purchases.month = #{current_month_first_day.month}))") .where("lesson_package_purchases.id IS NULL OR (lesson_package_purchases.id IS NOT NULL AND lesson_package_purchases.post_processed = false)") .where(payment_style: PAYMENT_STYLE_MONTHLY) .active .where('music_sessions.scheduled_start >= ?', current_month_first_day) .where('music_sessions.scheduled_start <= ?', current_month_last_day).uniq =begin today = now.to_date seven_days_in_future = today + 7 is_different_month = seven_days_in_future.month != today.month if is_different_month condition = "(((lesson_package_purchases.year = #{current_month_first_day.year} AND lesson_package_purchases.month = #{current_month_first_day.month}) AND ( (EXTRACT(YEAR FROM lesson_sessions.created_at)) = #{current_month_first_day.year} AND (EXTRACT(MONTH FROM lesson_sessions.created_at)) = #{current_month_first_day.month} ) ) OR ((lesson_package_purchases.year = #{seven_days_in_future.year} AND lesson_package_purchases.month = #{seven_days_in_future.month}) AND ( (EXTRACT(YEAR FROM lesson_sessions.created_at)) = #{seven_days_in_future.year} AND (EXTRACT(MONTH FROM lesson_sessions.created_at)) = #{seven_days_in_future.month} ) ) )" else condition = "((lesson_package_purchases.year = #{current_month_first_day.year} AND lesson_package_purchases.month = #{current_month_first_day.month}) AND ( (EXTRACT(YEAR FROM lesson_sessions.created_at)) = #{current_month_first_day.year} AND (EXTRACT(MONTH FROM lesson_sessions.created_at)) = #{current_month_first_day.month} ) )" end # .where("(lesson_package_purchases.year = #{current_month_first_day.year} AND lesson_package_purchases.month = #{current_month_first_day.month}) OR (lesson_package_purchases.year = #{next_month_last_day.year} AND lesson_package_purchases.month = #{next_month_last_day.month})") # find any monthly-billed bookings that have a session coming up within 7 days, and if so, attempt to bill them LessonBooking .joins(:lesson_sessions) .joins("LEFT JOIN lesson_package_purchases ON (lesson_package_purchases.lesson_booking_id = lesson_bookings.id AND #{condition})") .where("lesson_package_purchases.id IS NULL OR (lesson_package_purchases.id IS NOT NULL AND lesson_package_purchases.post_processed = false)") .where(payment_style: PAYMENT_STYLE_MONTHLY) .where(status: STATUS_APPROVED) .where('lesson_sessions.created_at >= ?', current_month_first_day) .where('lesson_sessions.created_at <= ?', seven_days_in_future).uniq =end end def self.bookings(student, teacher, since_at = nil) bookings = LessonBooking.where(user_id: student.id, teacher_id: teacher.id) if since_at bookings = bookings.where('created_at >= ?', since_at) end bookings end def self.engaged_bookings(student, teacher, since_at = nil) bookings = bookings(student, teacher, since_at) bookings.engaged end def bill_monthly(now) LessonBooking.transaction do self.lock! current_month = Date.new(now.year, now.month, 1) bill_for_month(current_month) today = now.to_date seven_days_in_future = today + 7 is_different_month = seven_days_in_future.month != today.month if is_different_month bill_for_month(seven_days_in_future) end end end def bill_for_month(day_in_month) # try to find lesson package purchase for this month, and last month, and see if they need processing current_month_purchase = lesson_package_purchases.where(lesson_booking_id: self.id, user_id: student.id, year: day_in_month.year, month: day_in_month.month).first if current_month_purchase.nil? current_month_purchase = LessonPackagePurchase.create(user, self, lesson_package_type, day_in_month.year, day_in_month.month) end current_month_purchase.bill_monthly end def suspend! # when this is called, the calling code sends out a email to let the student and teacher know (it feels unnatural it's not here, though) self.status = STATUS_SUSPENDED self.active = false if self.save future_sessions.each do |lesson_session| LessonSession.find(lesson_session.id).suspend! end end end def unsuspend! if self.status == STATUS_SUSPENDED self.status = STATUS_APPROVED self.active = true if self.save future_sessions.each do |lesson_session| LessonSession.find(lesson_session.id).unsuspend! end end end end def future_sessions lesson_sessions.joins(:music_session).where('scheduled_start > ?', Time.now).order(:created_at) end def self.schedule_upcoming_lessons minimum_start_time = (Time.now + APP_CONFIG.minimum_lesson_booking_hrs * 60*60) lesson_bookings = find_bookings_needing_sessions(minimum_start_time) lesson_bookings.each do |data| lesson_booking = LessonBooking.find(data["lesson_booking_id"]) lesson_booking.sync_lessons end end def home_url APP_CONFIG.external_root_url + "/client#/jamclass" end def web_url APP_CONFIG.external_root_url + "/client#/jamclass/lesson-booking/" + id end def update_payment_url APP_CONFIG.external_root_url + "/client#/jamclass/update-payment" end def admin_url APP_CONFIG.admin_root_url + "/admin/lesson_bookings/" + id end end end