602 lines
19 KiB
Ruby
602 lines
19 KiB
Ruby
# represenst the type of lesson package
|
||
module JamRuby
|
||
class LessonSession < ActiveRecord::Base
|
||
|
||
|
||
attr_accessor :accepting, :creating, :countering, :countered_slot, :countered_lesson
|
||
|
||
@@log = Logging.logger[LessonSession]
|
||
|
||
STATUS_REQUESTED = 'requested'
|
||
STATUS_CANCELED = 'canceled'
|
||
STATUS_MISSED = 'missed'
|
||
STATUS_COMPLETED = 'completed'
|
||
STATUS_APPROVED = 'approved'
|
||
STATUS_SUSPENDED = 'suspended'
|
||
|
||
STATUS_TYPES = [STATUS_REQUESTED, STATUS_CANCELED, STATUS_MISSED, STATUS_COMPLETED, STATUS_APPROVED, STATUS_SUSPENDED]
|
||
|
||
LESSON_TYPE_SINGLE = 'paid'
|
||
LESSON_TYPE_SINGLE_FREE = 'single-free'
|
||
LESSON_TYPE_TEST_DRIVE = 'test-drive'
|
||
LESSON_TYPES = [LESSON_TYPE_SINGLE, LESSON_TYPE_SINGLE_FREE, LESSON_TYPE_TEST_DRIVE]
|
||
|
||
has_one :music_session, class_name: "JamRuby::MusicSession"
|
||
belongs_to :teacher, class_name: "JamRuby::User", foreign_key: :teacher_id, inverse_of: :taught_lessons
|
||
belongs_to :lesson_package_purchase, class_name: "JamRuby::LessonPackagePurchase"
|
||
belongs_to :lesson_booking, class_name: "JamRuby::LessonBooking"
|
||
belongs_to :slot, class_name: "JamRuby::LessonBookingSlot", foreign_key: :slot_id
|
||
has_many :lesson_booking_slots, class_name: "JamRuby::LessonBookingSlot"
|
||
|
||
validates :duration, presence: true, numericality: {only_integer: true}
|
||
validates :lesson_booking, presence: true
|
||
validates :lesson_type, inclusion: {in: LESSON_TYPES}
|
||
validates :booked_price, presence: true
|
||
validates :status, presence: true, inclusion: {in: STATUS_TYPES}
|
||
validates :teacher_complete, inclusion: {in: [true, false]}
|
||
validates :student_complete, inclusion: {in: [true, false]}
|
||
validates :teacher_canceled, inclusion: {in: [true, false]}
|
||
validates :student_canceled, inclusion: {in: [true, false]}
|
||
validates :success, inclusion: {in: [true, false]}
|
||
validates :bill, inclusion: {in: [true, false]}
|
||
validates :billed, inclusion: {in: [true, false]}
|
||
validates :post_processed, inclusion: {in: [true, false]}
|
||
|
||
validate :validate_creating, :if => :creating
|
||
validate :validate_accepted, :if => :accepting
|
||
after_save :after_counter, :if => :countering
|
||
after_save :manage_slot_changes
|
||
|
||
def manage_slot_changes
|
||
# if this slot changed, we need to update the time. But LessonBooking does this for us, for requested/accepted .
|
||
# TODO: what to do, what to do.
|
||
|
||
end
|
||
|
||
def suspend!
|
||
self.status = STATUS_SUSPENDED
|
||
self.save
|
||
end
|
||
|
||
def self.hourly_check
|
||
analyse_sessions
|
||
complete_sessions
|
||
end
|
||
|
||
def self.analyse_sessions
|
||
MusicSession.joins(lesson_session: :lesson_booking).where('session_removed_at IS NOT NULL').where('analysed = false').each do |music_session|
|
||
lession_session = music_session.lesson_session
|
||
lession_session.analyse
|
||
end
|
||
end
|
||
|
||
def self.complete_sessions
|
||
MusicSession.joins(lesson_session: :lesson_booking).where('session_removed_at IS NOT NULL').where('analysed = true').where('post_processed = false').where('billing_should_retry = true').each do |music_session|
|
||
lession_session = music_session.lesson_session
|
||
lession_session.session_completed
|
||
end
|
||
end
|
||
|
||
def analyse
|
||
if self.analysed
|
||
return
|
||
end
|
||
|
||
analysis = LessonSessionAnalyser.analyse(self)
|
||
|
||
self.analysis = analysis_to_json(analysis)
|
||
self.success = analysis[:bill]
|
||
self.analysed_at = Time.now
|
||
self.analysed = true
|
||
|
||
if lesson_booking.requires_per_session_billing?
|
||
self.bill = true
|
||
end
|
||
|
||
if self.save
|
||
# send out emails appropriate for this type of session
|
||
session_completed
|
||
end
|
||
end
|
||
|
||
|
||
def amount_charged
|
||
if lesson_package_purchase
|
||
lesson_package_purchase.sale_line_item.sale.recurly_total_in_cents / 100.0
|
||
else
|
||
nil
|
||
end
|
||
end
|
||
|
||
def analysis_to_json(analysis)
|
||
json = {}
|
||
|
||
analysis.each do |k, v|
|
||
if v.is_a?(Array)
|
||
array = []
|
||
v.each do |item|
|
||
if item.is_a?(Range)
|
||
array << {begin: item.begin, end: item.end}
|
||
else
|
||
raise "expected range"
|
||
end
|
||
end
|
||
json[k] = array
|
||
else
|
||
json[k] = v
|
||
end
|
||
end
|
||
json.to_json
|
||
end
|
||
|
||
def session_completed
|
||
LessonSession.transaction do
|
||
self.lock!
|
||
|
||
if post_processed
|
||
# nothing to do. because this is an async job, it's possible this was queued up with another session_completed fired
|
||
return
|
||
end
|
||
|
||
begin
|
||
if lesson_booking.is_test_drive?
|
||
test_drive_completed
|
||
elsif lesson_booking.is_normal?
|
||
if lesson_booking.is_weekly_payment? || lesson_booking.is_monthly_payment?
|
||
recurring_completed
|
||
else
|
||
normal_lesson_completed
|
||
end
|
||
end
|
||
rescue Exception => e
|
||
self.unhandled_handler(e)
|
||
end
|
||
|
||
end
|
||
end
|
||
|
||
|
||
def bill_lesson
|
||
|
||
if !self.billed
|
||
|
||
# check if we can bill at the moment
|
||
if last_billing_attempt_at && (24.hours.ago < last_billing_attempt_at)
|
||
return
|
||
end
|
||
|
||
if !billing_should_retry
|
||
return
|
||
end
|
||
|
||
|
||
# bill the user right now. if it fails, move on; will be tried again
|
||
self.billing_attempts = self.billing_attempts + 1
|
||
self.billing_should_retry = self.billing_attempts < 5
|
||
self.last_billing_attempt_at = Time.now
|
||
self.save(validate: false)
|
||
|
||
begin
|
||
|
||
sale = Sale.purchase_lesson(student, lesson_booking, lesson_booking.lesson_package_type, self)
|
||
|
||
if sale.errors.any?
|
||
self.billing_error_reason = 'sale_error'
|
||
self.billing_error_detail = sale.errors.inspect
|
||
line_item = sale.sale_line_items[0]
|
||
if line_item && line_item.errors.any?
|
||
self.billing_error_detail = "#{self.billing_error_detail}\n\n#{line_item.errors.inspect}"
|
||
end
|
||
self.save(validate: false)
|
||
return false
|
||
else
|
||
self.billed = true
|
||
self.billed_at = Time.now
|
||
self.save(validate: false)
|
||
end
|
||
rescue Stripe::StripeError => e
|
||
|
||
stripe_handler(e)
|
||
|
||
subject = "Unable to charge user #{student.email} for lesson #{self.id} (stripe)"
|
||
body = "teacher=#{teacher.email}\n\nbilling_error_reason=#{billing_error_reason}\n\nbilling_error_detail = #{billing_error_detail}"
|
||
AdminMailer.alerts({subject: subject, body: body})
|
||
UserMailer.student_unable_charge(self)
|
||
return false
|
||
rescue Exception => e
|
||
subject = "Unable to charge user #{student.email} for lesson #{self.id} (unhandled)"
|
||
body = "teacher=#{teacher.email}\n\nbilling_error_detail = #{billing_error_detail}"
|
||
AdminMailer.alerts({subject: subject, body: body})
|
||
unhandled_handler(e)
|
||
return false
|
||
end
|
||
|
||
end
|
||
|
||
if !self.sent_billing_notices
|
||
# If the charge is successful, then we post the charge to the student’s payment history,
|
||
# and associate the charge with the lesson, so that everyone knows the student has paid, and we send an email
|
||
|
||
UserMailer.student_lesson_normal_done(self).deliver
|
||
UserMailer.teacher_lesson_normal_done(self).deliver
|
||
|
||
self.sent_billing_notices = true
|
||
self.sent_billing_notices_at = Time.now
|
||
self.post_processed = true
|
||
self.post_processed_at = Time.now
|
||
self.save(validate: false)
|
||
end
|
||
|
||
|
||
return true
|
||
end
|
||
|
||
def unhandled_handler(e, reason = 'unhandled_exception')
|
||
self.billing_error_reason = reason
|
||
if e.cause
|
||
self.billing_error_detail = e.cause.to_s + "\n" + e.cause.backtrace.join("\n\t") if e.cause.backtrace
|
||
self.billing_error_detail << "\n\n"
|
||
self.billing_error_detail << e.to_s + "\n" + e.backtrace.join("\n\t") if e.backtrace
|
||
else
|
||
self.billing_error_detail = e.to_s + "\n" + e.backtrace.join("\n\t") if e.backtrace
|
||
end
|
||
self.save(validate: :false)
|
||
end
|
||
|
||
def is_card_declined?
|
||
billed == false && billing_error_reason == 'card_declined'
|
||
end
|
||
|
||
def is_card_expired?
|
||
billed == false && billing_error_reason == 'card_expired'
|
||
end
|
||
|
||
def last_billed_at_date
|
||
last_billing_attempt_at.strftime("%B %d, %Y") if last_billing_attempt_at
|
||
end
|
||
def stripe_handler(e)
|
||
|
||
msg = e.to_s
|
||
|
||
if msg.include?('declined')
|
||
self.billing_error_reason = 'card_declined'
|
||
self.billing_error_detail = msg
|
||
elsif msg.include?('expired')
|
||
self.billing_error_reason = 'card_expired'
|
||
self.billing_error_detail = msg
|
||
elsif msg.include?('processing')
|
||
self.billing_error_reason = 'processing_error'
|
||
self.billing_error_detail = msg
|
||
else
|
||
self.billing_error_reason = 'stripe'
|
||
self.billing_error_detail = msg
|
||
end
|
||
|
||
self.save(validate: false)
|
||
end
|
||
|
||
def test_drive_completed
|
||
if !sent_billing_notices
|
||
if success
|
||
student.test_drive_succeeded(self)
|
||
else
|
||
student.test_drive_failed(self)
|
||
end
|
||
|
||
self.sent_billing_notices = true
|
||
self.sent_billing_notices_at = Time.now
|
||
self.post_processed = true
|
||
self.post_processed_at = Time.now
|
||
self.save(:validate => false)
|
||
end
|
||
end
|
||
|
||
def recurring_completed
|
||
if success
|
||
if lesson_booking.is_monthly_payment?
|
||
# monthly payments are handled at beginning of month; just poke with email, and move on
|
||
|
||
if !sent_billing_notices
|
||
# not in spec; just poke user and tell them we saw it was successfully completed
|
||
UserMailer.monthly_recurring_done(user, lesson_session).deliver
|
||
|
||
self.sent_billing_notices = true
|
||
self.sent_billing_notices_at = Time.now
|
||
self.post_processed = true
|
||
self.post_processed_at = Time.now
|
||
self.save(:validate => false)
|
||
end
|
||
else
|
||
bill_lesson
|
||
end
|
||
else
|
||
if lesson_booking.is_monthly_payment?
|
||
# bad session; just poke user
|
||
if !sent_billing_notices
|
||
UserMailer.monthly_recurring_no_bill(user, self).deliver
|
||
self.sent_billing_notices = true
|
||
self.sent_billing_notices_at = Time.now
|
||
self.post_processed = true
|
||
self.post_processed_at = Time.now
|
||
self.save(:validate => false)
|
||
end
|
||
|
||
else
|
||
if !sent_billing_notices
|
||
# bad session; just poke user
|
||
UserMailer.student_weekly_recurring_no_bill(user, self).deliver
|
||
self.sent_billing_notices = true
|
||
self.sent_billing_notices_at = Time.now
|
||
self.post_processed = true
|
||
self.post_processed_at = Time.now
|
||
self.save(:validate => false)
|
||
end
|
||
|
||
end
|
||
end
|
||
end
|
||
|
||
def normal_lesson_completed
|
||
if success
|
||
bill_lesson
|
||
else
|
||
if !sent_billing_notices
|
||
UserMailer.student_lesson_normal_no_bill(self).deliver
|
||
UserMailer.teacher_lesson_no_bill(self).deliver
|
||
self.sent_billing_notices = true
|
||
self.sent_billing_notices_at = Time.now
|
||
self.post_processed = true
|
||
self.post_processed_at = Time.now
|
||
self.save(:validate => false)
|
||
end
|
||
end
|
||
end
|
||
|
||
def after_counter
|
||
send_counter(@countered_lesson, @countered_slot)
|
||
end
|
||
|
||
def scheduled_start
|
||
music_session.scheduled_start
|
||
end
|
||
|
||
def send_counter(countered_lesson, countered_slot)
|
||
if countered_slot.is_teacher_created?
|
||
UserMailer.student_lesson_counter(countered_lesson, countered_slot).deliver
|
||
else
|
||
UserMailer.teacher_lesson_counter(countered_lesson, countered_slot).deliver
|
||
end
|
||
self.countering = false
|
||
end
|
||
|
||
default_scope { order(:created_at) }
|
||
|
||
def is_requested?
|
||
status == STATUS_REQUESTED
|
||
end
|
||
|
||
def is_canceled?
|
||
status == STATUS_CANCELED
|
||
end
|
||
|
||
def is_completed?
|
||
status == STATUS_COMPLETED
|
||
end
|
||
|
||
def is_missed?
|
||
status == STATUS_MISSED
|
||
end
|
||
|
||
def is_approved?
|
||
status == STATUS_APPROVED
|
||
end
|
||
|
||
def is_suspended?
|
||
status == STATUS_SUSPENDED
|
||
end
|
||
|
||
def validate_creating
|
||
if !is_requested? && !is_approved?
|
||
self.errors.add(:status, "is not valid for a new lesson session.")
|
||
end
|
||
|
||
if is_approved? && lesson_booking && lesson_booking.is_test_drive? && lesson_package_purchase.nil?
|
||
self.errors.add(:lesson_package_purchase, "must be specified for a test drive purchase")
|
||
end
|
||
end
|
||
|
||
def validate_accepted
|
||
if !is_requested?
|
||
self.errors.add(:status, "This session is already #{self.status}.")
|
||
end
|
||
|
||
if lesson_booking && lesson_booking.is_test_drive? && lesson_package_purchase.nil?
|
||
self.errors.add(:lesson_package_purchase, "must be specified for a test drive purchase")
|
||
end
|
||
|
||
self.accepting = false
|
||
self.status = STATUS_APPROVED
|
||
end
|
||
|
||
def self.create(booking)
|
||
lesson_session = LessonSession.new
|
||
lesson_session.creating = true
|
||
lesson_session.duration = booking.lesson_length
|
||
lesson_session.lesson_type = booking.lesson_type
|
||
lesson_session.lesson_booking = booking
|
||
lesson_session.booked_price = booking.booked_price
|
||
lesson_session.teacher = booking.teacher
|
||
lesson_session.status = booking.status
|
||
lesson_session.slot = booking.default_slot
|
||
if booking.is_test_drive?
|
||
lesson_session.lesson_package_purchase = booking.student.most_recent_test_drive_purchase
|
||
end
|
||
lesson_session.save
|
||
|
||
if lesson_session.errors.any?
|
||
puts "Lesson Session errors #{lesson_session.errors.inspect}"
|
||
end
|
||
lesson_session
|
||
end
|
||
|
||
def student
|
||
music_session.creator
|
||
end
|
||
|
||
def self.index(user, params = {})
|
||
limit = params[:per_page]
|
||
limit ||= 100
|
||
limit = limit.to_i
|
||
|
||
query = LessonSession.joins(:music_session).joins(music_session: :creator)
|
||
query = query.includes([:teacher, :music_session])
|
||
query = query.order('music_sessions.scheduled_start DESC')
|
||
|
||
if params[:as_teacher]
|
||
query = query.where('lesson_sessions.teacher_id = ?', user.id)
|
||
else
|
||
query = query.where('music_sessions.user_id = ?', user.id)
|
||
end
|
||
|
||
|
||
current_page = params[:page].nil? ? 1 : params[:page].to_i
|
||
next_page = current_page + 1
|
||
|
||
# will_paginate gem
|
||
query = query.paginate(:page => current_page, :per_page => limit)
|
||
|
||
if query.length == 0 # no more results
|
||
{query: query, next_page: nil}
|
||
elsif query.length < limit # no more results
|
||
{query: query, next_page: nil}
|
||
else
|
||
{query: query, next_page: next_page}
|
||
end
|
||
end
|
||
|
||
def update_scheduled_start(week_offset)
|
||
music_session.scheduled_start = slot.scheduled_time(week_offset)
|
||
music_session.save
|
||
end
|
||
|
||
# grabs the next available time that's after the present, to avoid times being scheduled in the past
|
||
def update_next_available_time(attempt = 0)
|
||
max_attempts = attempt + 10
|
||
while attempt < max_attempts
|
||
test = slot.scheduled_time(attempt)
|
||
|
||
if test >= Time.now
|
||
time = test
|
||
# valid time found!
|
||
break
|
||
end
|
||
attempt += 1
|
||
end
|
||
|
||
if time
|
||
music_session.scheduled_start = time
|
||
music_session.save
|
||
end
|
||
|
||
time.nil? ? nil : attempt
|
||
end
|
||
|
||
# teacher accepts the lesson
|
||
def accept(params)
|
||
LessonSession.transaction do
|
||
|
||
message = params[:message]
|
||
slot = params[:slot]
|
||
slot = LessonBookingSlot.find(slot)
|
||
self.slot = slot
|
||
|
||
if is_approved?
|
||
# this implies a new slot has been countered, and now approved
|
||
|
||
if self.save
|
||
if slot.update_all
|
||
lesson_booking.accept(self, slot)
|
||
msg = ChatMessage.create(teacher, nil, message, ChatMessage::CHANNEL_LESSON, nil, student, lesson_booking)
|
||
Notification.send_lesson_message('accept', self, true) # TODO: this isn't quite an 'accept'
|
||
UserMailer.student_lesson_update_all(self, message, slot).deliver
|
||
UserMailer.teacher_lesson_update_all(self, message, slot).deliver
|
||
else
|
||
# nothing to do with the original booking (since we are not changing all times), so we update just ourself
|
||
time = update_next_available_time # XXX: week offset as 0? This *could* still be in the past. But the user is approving it. So do we just trust them and get out of their way?
|
||
|
||
if time.nil?
|
||
@@log.error("unable to accept slot #{slot.id} for lesson #{self.id} because it's in the past")
|
||
puts("unable to accept slot #{slot.id} for lesson #{self.id} because it's in the past")
|
||
raise ActiveRecord::Rollback
|
||
end
|
||
UserMailer.student_lesson_accepted(self, message, slot).deliver
|
||
UserMailer.teacher_lesson_accepted(self, message, slot).deliver
|
||
end
|
||
else
|
||
@@log.error("unable to accept slot #{slot.id} for lesson #{self.id}")
|
||
puts("unable to accept slot #{slot.id} for lesson #{self.id}")
|
||
end
|
||
else
|
||
self.accepting = true
|
||
|
||
if lesson_package_purchase.nil? && lesson_booking.is_test_drive?
|
||
self.lesson_package_purchase = student.most_recent_test_drive_purchase
|
||
end
|
||
|
||
if self.save
|
||
lesson_booking.accept(self, slot)
|
||
msg = ChatMessage.create(teacher, nil, message, ChatMessage::CHANNEL_LESSON, nil, student, lesson_booking)
|
||
Notification.send_lesson_message('accept', self, true)
|
||
UserMailer.student_lesson_accepted(self, message, slot).deliver
|
||
UserMailer.teacher_lesson_accepted(self, message, slot).deliver
|
||
else
|
||
@@log.error("unable to accept slot #{slot.id} for lesson #{self.id}")
|
||
puts("unable to accept slot #{slot.id} for lesson #{self.id}")
|
||
end
|
||
end
|
||
|
||
|
||
end
|
||
end
|
||
|
||
def counter(params)
|
||
proposer = params[:proposer]
|
||
slot = params[:slot]
|
||
message = params[:message]
|
||
|
||
self.countering = true
|
||
slot.proposer = proposer
|
||
slot.lesson_session = self
|
||
self.lesson_booking_slots << slot
|
||
self.countered_slot = slot
|
||
self.countered_lesson = self
|
||
if self.save
|
||
if slot.update_all || lesson_booking.is_requested?
|
||
lesson_booking.counter(self, proposer, slot)
|
||
end
|
||
else
|
||
raise ActiveRecord::Rollback
|
||
end
|
||
|
||
msg = ChatMessage.create(slot.proposer, music_session, message, ChatMessage::CHANNEL_LESSON, nil, slot.recipient, lesson_booking)
|
||
Notification.send_lesson_message('counter', self, slot.is_teacher_created?)
|
||
end
|
||
|
||
def home_url
|
||
APP_CONFIG.external_root_url + "/client#/jamclass"
|
||
end
|
||
|
||
def web_url
|
||
APP_CONFIG.external_root_url + "/client#/jamclass/lesson-request/" + 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_sessions/" + id
|
||
end
|
||
end
|
||
end
|