1244 lines
42 KiB
Ruby
1244 lines
42 KiB
Ruby
# 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, :autocanceling, :countered_slot, :countered_lesson, :current_purchase, :current_lesson, :expected_session_times, :adjustment_in_cents
|
|
|
|
STATUS_REQUESTED = 'requested'
|
|
STATUS_CANCELED = 'canceled'
|
|
STATUS_APPROVED = 'approved'
|
|
STATUS_SUSPENDED = 'suspended'
|
|
STATUS_COUNTERED = 'countered'
|
|
STATUS_COMPLETED = 'completed'
|
|
STATUS_UNCONFIRMED = 'unconfirmed'
|
|
|
|
STATUS_TYPES = [STATUS_REQUESTED, STATUS_CANCELED, STATUS_APPROVED, STATUS_SUSPENDED, STATUS_COUNTERED, STATUS_COMPLETED, STATUS_UNCONFIRMED]
|
|
|
|
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'
|
|
|
|
ENGAGED = "status = '#{STATUS_APPROVED}' OR status = '#{STATUS_REQUESTED}' OR status = '#{STATUS_SUSPENDED}'"
|
|
|
|
|
|
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 :counterer, class_name: "JamRuby::User", foreign_key: :counterer_id
|
|
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"
|
|
belongs_to :retailer, class_name: "JamRuby::Retailer"
|
|
belongs_to :test_drive_package_choice, class_name: "JamRuby::TestDrivePackageChoice"
|
|
belongs_to :posa_card, class_name: "JamRuby::PosaCard"
|
|
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 :same_school, 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(ENGAGED) }
|
|
scope :engaged_or_successful, -> { where("(" + ENGAGED + ") OR (lesson_bookings.status = '#{STATUS_COMPLETED}' AND lesson_bookings.success = true)") }
|
|
|
|
def before_validation
|
|
if self.booked_price.nil?
|
|
self.booked_price = compute_price
|
|
end
|
|
end
|
|
|
|
def after_create
|
|
if ((posa_card && posa_card.purchased) || card_presumed_ok || !payment_if_school_on_school?) && !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
|
|
if is_test_drive?
|
|
real_price = 0
|
|
elsif is_monthly_payment?
|
|
raise "no purchase assigned to lesson booking for monthly payment!" if current_purchase.nil?
|
|
real_price = self.current_purchase.teacher_distribution.jamkazam_margin
|
|
else
|
|
if current_lesson.nil?
|
|
puts "OHOHOMOOMG #{self.inspect}"
|
|
raise "no purchase assigned to lesson booking for lesson!"
|
|
end
|
|
|
|
real_price = self.current_lesson.teacher_distribution.jamkazam_margin
|
|
end
|
|
{price: real_price, real_price: real_price, total_price: real_price}
|
|
end
|
|
|
|
# here for shopping_cart
|
|
def price
|
|
booked_price
|
|
end
|
|
|
|
def is_countered?
|
|
has_recurring_counter?
|
|
end
|
|
|
|
def has_recurring_counter?
|
|
!!self.counter_slot && self.counter_slot.is_recurring?
|
|
end
|
|
|
|
def ever_accepted?
|
|
!!self.accepter
|
|
end
|
|
|
|
def no_slots
|
|
default_slot.from_package
|
|
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
|
|
if slot.is_recurring?
|
|
if self.recurring
|
|
self.counter_slot = nil
|
|
self.status = STATUS_APPROVED
|
|
self.default_slot = slot
|
|
self.accepter = accepter
|
|
else
|
|
# should never happen because u shouldn't be able to set a recurring slot on a single lesson
|
|
end
|
|
else
|
|
self.status = STATUS_APPROVED
|
|
self.default_slot = slot
|
|
self.accepter = accepter
|
|
end
|
|
|
|
success = self.save
|
|
|
|
if !success
|
|
puts "unable to accept lesson booking #{errors.inspect}"
|
|
else
|
|
# ok, now we have to update the slots of our lesson_sessions
|
|
self.lesson_sessions.each do |lesson_session|
|
|
if !lesson_session.is_countered?
|
|
lesson_session.slot = slot
|
|
lesson_session.save
|
|
end
|
|
|
|
end
|
|
end
|
|
success
|
|
end
|
|
|
|
def counter(lesson_session, proposer, slot)
|
|
|
|
if slot.is_recurring?
|
|
self.lesson_booking_slots << slot
|
|
self.countering = true
|
|
self.counter_slot = slot
|
|
self.counterer = proposer
|
|
self.countered_at = Time.now
|
|
self.sent_counter_reminder = false
|
|
self.status = STATUS_COUNTERED
|
|
end
|
|
|
|
if self.default_slot.from_package
|
|
self.default_slot = slot
|
|
end
|
|
self.save
|
|
end
|
|
|
|
def automatically_default_slot
|
|
if is_requested? && default_slot.nil?
|
|
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 (posa_card || 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?
|
|
if posa_card
|
|
user.jamclass_credits = user.jamclass_credits - 1
|
|
else
|
|
user.remaining_test_drives = user.remaining_test_drives - 1
|
|
end
|
|
|
|
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? || is_completed?
|
|
# 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").order(:created_at)
|
|
if recurring
|
|
# only want times ahead of this for recurring
|
|
sessions = sessions.where("scheduled_start > ?", minimum_start_time)
|
|
end
|
|
|
|
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 no_school_on_school_payment?
|
|
return false
|
|
elsif 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_completed?
|
|
status == STATUS_COMPLETED
|
|
end
|
|
|
|
def is_approved?
|
|
status == STATUS_APPROVED
|
|
end
|
|
|
|
def is_suspended?
|
|
status == STATUS_SUSPENDED
|
|
end
|
|
|
|
def is_active?
|
|
active
|
|
end
|
|
|
|
def has_access?(user)
|
|
user.id == student.id || user.id == teacher.id || (self.school.nil? ? false : self.school.user.id == user.id)
|
|
end
|
|
|
|
def validate_accepted
|
|
# accept is multipe purpose; either accept the initial request, or a counter slot
|
|
if self.status_was != STATUS_REQUESTED && self.status_was != STATUS_COUNTERED
|
|
self.errors.add(:status, "This lesson is already #{self.status}.")
|
|
end
|
|
|
|
if self.accepter.nil?
|
|
self.errors.add(:accepter, "No one has been indicated as accepting the lesson")
|
|
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_now
|
|
UserMailer.teacher_lesson_request(self).deliver_now
|
|
Notification.send_lesson_message('requested', lesson_sessions[0], false) # TODO: this isn't quite an 'accept'
|
|
self.sent_notices = true
|
|
self.sent_notices_at = Time.now
|
|
self.save
|
|
end
|
|
|
|
def resolved_test_drive_package
|
|
result = nil
|
|
# posa card is best indicator of lesson package type
|
|
if posa_card
|
|
return posa_card.lesson_package_type
|
|
end
|
|
|
|
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, education, split = nil)
|
|
|
|
|
|
if split
|
|
distribution = teacher_distribution_price_in_cents(target, split)
|
|
(distribution * split).round
|
|
elsif education
|
|
distribution = teacher_distribution_price_in_cents(target, 0.0625)
|
|
(distribution * 0.0625).round # 0.0625 is 1/4th of 25%
|
|
else
|
|
distribution = teacher_distribution_price_in_cents(target)
|
|
distribution
|
|
end
|
|
end
|
|
|
|
def teacher_distribution_price_in_cents(target, split = nil)
|
|
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
|
|
price, times = LessonSessionMonthlyPrice.price(self, start_date)
|
|
|
|
price_in_cents = (price * 100).round
|
|
# OK, we have a suggested price based on date, but we need to now adjust if previous lessons have been unsuccessful
|
|
|
|
adjusted_price_in_cents = LessonSessionMonthlyPrice.adjust_for_missed_lessons(self, price_in_cents, split)
|
|
|
|
self.expected_session_times = times # save for later
|
|
self.adjustment_in_cents = price_in_cents - adjusted_price_in_cents
|
|
adjusted_price_in_cents
|
|
else
|
|
booked_price * 100
|
|
end
|
|
end
|
|
end
|
|
|
|
# find any lesson package purchases for this lesson booking for previous months that have not had adjustments
|
|
# we have to base this on 'now', meaning we do not consider months until they are fully closed
|
|
# this does mean, because we collect up to a week in advance of a new month starting, that a student will likely not see adjustments until 2 cycles forward
|
|
def self.previous_needing_adjustment
|
|
now = Time.now.utc
|
|
year = now.year
|
|
month = now.month
|
|
|
|
if month == 1
|
|
previous_year = year - 1
|
|
previous_month = 12
|
|
else
|
|
previous_year = year
|
|
previous_month = month - 1
|
|
end
|
|
|
|
LessonPackagePurchase.where(recurring: true).where('month <= ?', previous_month).where('year <= ?', previous_year).where('actual_session_times is null').where('expected_session_times is not null')
|
|
.limit(500)
|
|
end
|
|
|
|
def self.adjust_for_missed_sessions
|
|
|
|
# Go to previous lesson_package_purchase month, and see if we need to adjust for a roll forward due to missed sessions
|
|
previous_purchases = LessonBooking.previous_needing_adjustment
|
|
|
|
previous_purchases.each do |previous_purchase|
|
|
|
|
# XXX other monthly code uses session start time. should we be doing that?
|
|
successful_lessons = LessonSession.where(lesson_booking: previous_purchase.lesson_booking).where(success: true).where('analysed_at >= ? AND analysed_at < ?', previous_purchase.beginning_of_month_at, previous_purchase.end_of_month_at)
|
|
|
|
previous_purchase.actual_session_times = successful_lessons.count
|
|
# find out how many actual lessons were had, and then we can adjust price of this current distribution (amount_in_cents) accordingly
|
|
|
|
ratio = previous_purchase.actual_session_times.to_f / previous_purchase.expected_session_times.to_f
|
|
|
|
if ratio < 1
|
|
# discount next month for student
|
|
amount_paid_last_month_in_cents = previous_purchase.price_in_cents # this does not include tax. It's just the expected price of the booking
|
|
previous_purchase.remaining_roll_forward_amount_in_cents = previous_purchase.total_roll_forward_amount_in_cents = (amount_paid_last_month_in_cents * ratio).round
|
|
|
|
# if there is a roll forward, add it to the lesson booking
|
|
previous_purchase.lesson_booking.remaining_roll_forward_amount_in_cents += previous_purchase.remaining_roll_forward_amount_in_cents
|
|
previous_purchase.lesson_booking.save!
|
|
|
|
else
|
|
previous_purchase.total_roll_forward_amount_in_cents = 0
|
|
previous_purchase.applied_roll_forward_amount_in_cents = 0
|
|
end
|
|
|
|
previous_purchase.save
|
|
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.to_s.rjust(2, "0")}#{am_pm}"
|
|
|
|
end
|
|
|
|
def status_as_verb
|
|
if is_requested?
|
|
'requested'
|
|
else
|
|
'scheduled'
|
|
end
|
|
|
|
end
|
|
def approved_before?
|
|
!self.accepter_id.nil?
|
|
end
|
|
|
|
def autocancel
|
|
self.autocanceling = true
|
|
self.active = false
|
|
self.status = STATUS_UNCONFIRMED
|
|
save
|
|
self
|
|
end
|
|
|
|
def cancel_tracking(canceler, message, canceled_by_admin = false)
|
|
canceled_by_student = canceler == student
|
|
self.status = STATUS_CANCELED
|
|
self.cancel_message = message
|
|
self.canceler = canceler if !canceled_by_admin
|
|
self.canceling = true
|
|
|
|
if canceled_by_admin
|
|
self.canceled_by_admin = Time.now
|
|
elsif canceled_by_student
|
|
self.student_canceled = true
|
|
self.student_canceled_at = Time.now
|
|
self.student_canceled_reason = message
|
|
else
|
|
self.teacher_canceled = true
|
|
self.teacher_canceled_at = Time.now
|
|
self.teacher_canceled_reason = message
|
|
end
|
|
end
|
|
|
|
def cancel(canceler, other, message, canceled_by_admin = false)
|
|
|
|
cancel_tracking(canceler, message, canceled_by_admin)
|
|
self.active = false
|
|
|
|
success = save
|
|
if success
|
|
lesson_sessions.upcoming.each do |lesson_session|
|
|
lesson_session = LessonSession.find(lesson_session.id) # because .upcoming creates ReadOnly records
|
|
lesson_session.cancel_lesson(canceler, message, canceled_by_admin)
|
|
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_now
|
|
UserMailer.teacher_lesson_booking_canceled(self, message).deliver_now
|
|
purpose = "Lesson Canceled"
|
|
else
|
|
if canceled_by_admin
|
|
# meh. no two way comm here. just bail
|
|
#Notification.send_lesson_message('canceled', next_lesson, true)
|
|
UserMailer.student_lesson_booking_declined(self, message).deliver_now
|
|
UserMailer.teacher_lesson_booking_canceled(self, message).deliver_now
|
|
elsif 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_now
|
|
purpose = "Lesson Canceled"
|
|
else
|
|
# if it's the first time acceptance teacher, it was declined
|
|
UserMailer.student_lesson_booking_declined(self, message).deliver_now
|
|
Notification.send_lesson_message('declined', next_lesson, true)
|
|
purpose = "Lesson Declined"
|
|
end
|
|
end
|
|
|
|
message = '' if message.nil?
|
|
if !canceled_by_admin
|
|
msg = ChatMessage.create(canceler, nil, message, ChatMessage::CHANNEL_LESSON, nil, other, next_lesson, purpose)
|
|
end
|
|
else
|
|
end
|
|
|
|
|
|
self
|
|
end
|
|
|
|
def card_approved
|
|
self.card_presumed_ok = true
|
|
if posa_card_id
|
|
self.posa_card_purchased = true
|
|
end
|
|
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 posa_card
|
|
if !user.has_posa_credits?
|
|
errors.add(:user, "have no remaining jamclass credits")
|
|
end
|
|
else
|
|
if !user.has_test_drives? && !user.can_buy_test_drive?
|
|
errors.add(:user, "have no remaining test drives")
|
|
elsif teacher.has_booked_test_drive_with_student?(user) && !user.admin
|
|
# we don't want to restrict this anymore. just let'em go to same teacher
|
|
# errors.add(:user, "have an in-progress or successful TestDrive with this teacher already")
|
|
end
|
|
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 test_drive_package_choice.nil?
|
|
if lesson_booking_slots.length == 0 || lesson_booking_slots.length == 1
|
|
errors.add(:lesson_booking_slots, "must have two times specified")
|
|
end
|
|
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_packaged_test_drive(user, teacher, description, test_drive_package_choice)
|
|
book_test_drive(user, teacher, LessonBookingSlot.packaged_slots, description, test_drive_package_choice)
|
|
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, test_drive_package_choice = nil)
|
|
self.book(user, teacher, LessonBooking::LESSON_TYPE_TEST_DRIVE, lesson_booking_slots, false, 30, PAYMENT_STYLE_ELSEWHERE, description, test_drive_package_choice)
|
|
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, test_drive_package_choice = nil)
|
|
|
|
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_type == LESSON_TYPE_TEST_DRIVE
|
|
# if the user has any jamclass credits, then we should get their most recent posa purchase
|
|
if user.jamclass_credits > 0
|
|
lesson_booking.posa_card = user.most_recent_posa_card
|
|
if lesson_booking.posa_card
|
|
lesson_booking.posa_card_purchased = lesson_booking.posa_card.purchased
|
|
end
|
|
else
|
|
# otherwise, it's a normal test drive, and we should honor test_drive_package_choice if specified
|
|
lesson_booking.test_drive_package_choice = test_drive_package_choice
|
|
end
|
|
end
|
|
|
|
|
|
if lesson_booking.teacher && lesson_booking.teacher.teacher.school
|
|
lesson_booking.school = lesson_booking.teacher.teacher.school
|
|
end
|
|
|
|
# copy payment settings from retailer into lesson booking
|
|
if lesson_booking.teacher && lesson_booking.teacher.teacher.retailer
|
|
lesson_booking.retailer = lesson_booking.teacher.teacher.retailer
|
|
lesson_booking.payment = lesson_booking.teacher.teacher.retailer.payment_details.to_json
|
|
lesson_booking.same_retailer = lesson_booking.teacher.teacher.retailer.affiliate_partner == user.affiliate_referral
|
|
end
|
|
|
|
if user
|
|
lesson_booking.same_school = !!(lesson_booking.school && user.school && (lesson_booking.school.id == user.school.id))
|
|
if lesson_booking.same_school
|
|
lesson_booking.same_school_free = false # !user.school.education # non-education schools (music schools) are 'free' when school-on-school
|
|
end
|
|
user.update_timezone(lesson_booking_slots[0].timezone) if lesson_booking_slots.length > 0
|
|
else
|
|
lesson_booking.same_school = false
|
|
lesson_booking.same_school_free = false
|
|
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).where(same_school_free: false).where('posa_card_id is null OR (posa_card_id is not null AND posa_card_purchased = false)')
|
|
end
|
|
|
|
def self.requested(current_user)
|
|
LessonBooking.where(user_id: current_user.id).where(status: STATUS_REQUESTED)
|
|
end
|
|
|
|
def school_on_school?
|
|
same_school
|
|
end
|
|
|
|
def school_on_school_payment?
|
|
#!!(same_school && (school.education || school.is_guitar_center?))
|
|
same_school
|
|
end
|
|
|
|
def no_school_on_school_payment?
|
|
!!(school_on_school? && !school_on_school_payment?)
|
|
end
|
|
|
|
# if this is school-on-school, is payment required?
|
|
def payment_if_school_on_school?
|
|
!!(!school_on_school? || school_on_school_payment?)
|
|
end
|
|
|
|
def school_and_teacher
|
|
if school && school.scheduling_comm?
|
|
[school.communication_email, teacher.email]
|
|
else
|
|
[teacher.email]
|
|
end
|
|
end
|
|
|
|
def school_and_teacher_ids
|
|
if school && school.scheduling_comm?
|
|
[school.owner.id, teacher.id]
|
|
else
|
|
[teacher.id]
|
|
end
|
|
end
|
|
|
|
|
|
def school_over_teacher
|
|
if school && school.scheduling_comm?
|
|
[school.communication_email]
|
|
else
|
|
[teacher.email]
|
|
end
|
|
end
|
|
|
|
def school_over_teacher_ids
|
|
if school && school.scheduling_comm?
|
|
[school.owner.id]
|
|
else
|
|
[teacher.id]
|
|
end
|
|
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
|
|
|
|
# order matters: bill_monthly code will use the adjustments made in here to correct billing roll forward
|
|
adjust_for_missed_sessions
|
|
|
|
# needs to come after 'adjust_for_missed_sessions'
|
|
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)
|
|
.where(same_school_free: false)
|
|
.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.not_failed
|
|
|
|
end
|
|
|
|
def self.engaged_bookings(student, teacher, since_at = nil)
|
|
bookings = bookings(student, teacher, since_at)
|
|
bookings.engaged_or_successful
|
|
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 scheduling_email
|
|
school_scheduling_comm? ? school.communication_email : teacher.email
|
|
end
|
|
|
|
# when you need to email potentially both school and teacher for same email
|
|
def teacher_school_emails
|
|
if school_comm?
|
|
[school.communication_email, teacher.email]
|
|
else
|
|
[teacher.email]
|
|
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
|
|
|
|
private
|
|
def school_scheduling_comm?
|
|
school ? school.school_comm? : false
|
|
end
|
|
end
|
|
end
|