armandofox/audience1st

View on GitHub
app/models/valid_voucher.rb

Summary

Maintainability
C
7 hrs
Test Coverage
B
88%
=begin rdoc
A ValidVoucher is a record indicating the conditions under which a particular
voucher type can be redeemed.  For non-subscriptions, the valid voucher refers
to a particular showdate ID.  For subscriptions, the showdate ID is zero.
#
is a record that states "for a particular showdate ID, this particular type of voucher
is accepted", and encodes additional information such as the capacity limit for this vouchertype for thsi
 performance, the start and end dates of redemption for this vouchertype, etc.
=end

class ValidVoucher < ActiveRecord::Base

  # Capacity is infinite if it is left blank
  INFINITE = 100_000
  
  class InvalidRedemptionError < RuntimeError ;  end
  class InvalidProcessedByError < RuntimeError ; end

  belongs_to :showdate
  belongs_to :vouchertype
  # validate :self_service_comps_must_have_promo_code
  validates_associated :showdate, :if => lambda { |v| !(v.vouchertype.bundle?) }
  validates_associated :vouchertype
  validates_numericality_of :max_sales_for_type, :allow_nil => true, :greater_than_or_equal_to => 0
  validates_numericality_of :min_sales_per_txn, :greater_than_or_equal_to => 1, :less_than_or_equal_to => INFINITE, :message => "must be blank, or greater than or equal to 1"
  validates_numericality_of :max_sales_per_txn, :greater_than_or_equal_to => 1, :less_than_or_equal_to => INFINITE, :message => "must be blank, or greater than or equal to 1"
  validate :min_max_sales_constraints
  validates_presence_of :start_sales
  validates_presence_of :end_sales

  scope :for_ticket_products, -> { joins(:vouchertype).where('vouchertypes.category != ?', 'nonticket') }
  scope :sorted, -> { joins(:vouchertype).order('vouchertypes.display_order,vouchertypes.name') }

  def max_sales_for_type ; self[:max_sales_for_type] || INFINITE ; end
  def sales_unlimited?   ; max_sales_for_type >= INFINITE ; end

  validate :check_dates

  # for a given showdate ID, a particular vouchertype ID should be listed only once.
  validates_uniqueness_of :vouchertype_id, :scope => :showdate_id, :message => "already valid for this performance", :unless => lambda { |s| s.showdate_id.nil? }

  attr_accessor :customer, :supplied_promo_code # used only when checking visibility - not stored
  attr_accessor :explanation # tells customer/staff why the # of avail seats is what it is
  attr_accessor :visible     # should this offer be viewable by non-admins?
  alias_method :visible?, :visible # for convenience and more readable specs

  delegate :name, :price, :name_with_price, :display_order, :visible_to?, :season, :offer_public, :offer_public_as_string, :category, :comp?, :subscriber_voucher?, :zone_short_name, :to => :vouchertype
  delegate :<=>, :printable_name, :printable_date, :printable_date_with_description, :menu_selection_name, :name_and_date_with_capacity_stats, :saleable_seats_left, :thedate, :to => :showdate

  scope :for_shows, -> { where.not(:showdate => nil) }

  def public?
    [Vouchertype::SUBSCRIBERS, Vouchertype::ANYONE].include?(offer_public)
  end

  def event_type
    showdate.try(:show).try(:event_type)
  end

  def show_name
    showdate &&  showdate.show_name
  end

  attr_writer :max_sales_for_this_patron
  def max_sales_for_this_patron
    return @max_sales_for_this_patron.to_i if @max_sales_for_this_patron
    @max_sales_for_this_patron ||= max_sales_for_type()
    if showdate # in case this is a valid-voucher for a bundle, vs for regular show
      [@max_sales_for_this_patron, showdate.saleable_seats_left.to_i].min
    else
      @max_sales_for_this_patron.to_i
    end
  end

  # min and max dropdown menu options, plus an option to purchase zero tickets,
  # that respects min and max sales per txn as well as max of remaining seats etc.
  # Make the menu contain at most max choices.
  def min_and_max_sales_for_this_txn(max_choices = INFINITE)
    max_sales = [self.max_sales_for_this_patron,
                 self.max_sales_per_txn,
                 self.seats_of_type_remaining].
                  min
    min_sales = self.min_sales_per_txn
    if (max_sales.zero? ||   # maybe no seats of this type remaining
        max_sales < min_sales) # or just not enough
      [0]
    else # at this point, we know max_sales >= min_sales, and neither is zero
      range = [max_sales - min_sales + 1, max_choices].min
      [0] + (min_sales .. min_sales+range-1).to_a
    end
  end

  def display_min_and_max_sales_per_txn
    if min_sales_per_txn == 1
      max_sales_per_txn == INFINITE ? '' : "(max #{max_sales_per_txn} per order)"
    else                        # minimum order
      case max_sales_per_txn
      when min_sales_per_txn then "(#{min_sales_per_txn} per order)"
      when INFINITE then "(#{min_sales_per_txn}+ per order)"
      else "(#{min_sales_per_txn}-#{max_sales_per_txn} per order)"
      end
    end
  end

  private

  # A zero-price vouchertype that is marked as "available to public"
  # MUST have a promo code
  def self_service_comps_must_have_promo_code
    if vouchertype.self_service_comp? &&  promo_code.blank?
      errors.add(:promo_code, "must be provided for comps that are available for self-purchase")
    end
  end

  # Min/max sales per transaction cannot contradict min/max sales for type.
  # This is checked *after* each attribute is individually range-checked
  def min_max_sales_constraints
    errors.add :min_sales_per_txn, "cannot be greater than max allowed sales of this type"  if
      min_sales_per_txn > max_sales_for_type  &&  max_sales_for_type != 0
    errors.add :min_sales_per_txn, "cannot be greater than maximum purchase per transaction" if
      min_sales_per_txn > max_sales_per_txn
    errors.empty?
  end

  # Vouchertype's valid date must not be later than valid_voucher start date
  # Vouchertype expiration date must not be earlier than valid_voucher end date
  def check_dates
    return if start_sales.blank? || end_sales.blank? || vouchertype.nil?
    errors.add(:base,"Start sales time cannot be later than end sales time") and return if start_sales > end_sales
    vt = self.vouchertype
    if self.end_sales > (end_of_season = Time.current.at_end_of_season(vt.season))
      errors.add :base, "Voucher type '#{vt.name}' is valid for the
        season ending #{end_of_season.to_formatted_s(:showtime_including_year)},
        but you've indicated sales should continue later than that
        (until #{end_sales.to_formatted_s(:showtime_including_year)})."
    end
    self.end_sales = self.end_sales.rounded_to(:second)
  end

  def match_promo_code(str)
    promo_code.blank? || str.to_s.contained_in_or_blank(promo_code)
  end

  protected

  def adjust_for_visibility
    if !match_promo_code(supplied_promo_code)
      self.explanation = "Promo code #{promo_code.to_s.upcase} required"
      self.visible = false
    elsif !visible_to?(customer)
      self.explanation = "Ticket sales of this type restricted to #{offer_public_as_string}"
      self.visible = false
    end
    self.max_sales_for_this_patron = 0 if !self.explanation.blank?
    !self.explanation.blank?
  end

  def adjust_for_showdate
    if !showdate
      self.max_sales_for_this_patron = 0
      return nil
    end
    if showdate.sold_out?
      self.explanation = 'Event is sold out'
      self.visible = true
    end
    self.max_sales_for_this_patron = 0 if !self.explanation.blank?
    !self.explanation.blank?
  end

  def adjust_for_sales_dates
    now = Time.current
    if now < start_sales
      self.explanation = "Tickets of this type not on sale until #{start_sales.to_formatted_s(:showtime)}"
      self.visible = true
    elsif now > end_sales
      self.explanation = "Tickets of this type not sold after #{end_sales.to_formatted_s(:showtime)}"
      self.visible = true
    end
    self.max_sales_for_this_patron = 0 if !self.explanation.blank?
    !self.explanation.blank?
  end

  def adjust_for_advance_reservations
    if Time.current > end_sales
      self.explanation = 'Advance reservations for this performance are closed'
      self.max_sales_for_this_patron = 0
    end
    !self.explanation.blank?
  end

  def adjust_for_capacity
    self.max_sales_for_this_patron = seats_of_type_remaining()
    min_max = display_min_and_max_sales_per_txn
    self.explanation = 
      case max_sales_for_this_patron
      when 0 then "No seats remaining for tickets of this type"
      when INFINITE then "No performance-specific limit applies #{min_max}".strip
      else "#{max_sales_for_this_patron} remaining #{min_max}".strip
      end
    self.visible = true
  end

  def clone_with_id
    result = self.clone
    result.id = self.id # necessary since views expect valid-vouchers to have an id...
    result.visible = true
    result.customer = customer
    result.max_sales_for_this_patron = seats_of_type_remaining
    result.explanation = ''
    result
  end

  public
  
  def seats_of_type_remaining
    unless showdate
      self.explanation = "No limit"
      return INFINITE
    end
    if (map = showdate.seatmap)  &&  (zone = vouchertype.seating_zone)  #  zone limits?
      available_in_zone = map.seats_in_zone(zone) -
                          showdate.occupied_seats -
                          showdate.open_house_seats
      total_empty = available_in_zone.length
    else                        # general adm, or no zone limit on vouchertype
      total_empty = showdate.saleable_seats_left - showdate.open_house_seats.length
    end
    remain = if sales_unlimited? # no limit on ticket type: only limit is show capacity
             then total_empty
             else  [[max_sales_for_type - showdate.sales_by_type(vouchertype_id), 0].max, total_empty].min
             end
    remain = [remain, 0].max    # make sure it's positive
  end

  def self.bundles(seasons = [Time.this_season-1, Time.this_season+1])
    ValidVoucher.
      includes(:vouchertype,:showdate).references(:vouchertypes).
      where('vouchertypes.category' => 'bundle').
      where('vouchertypes.season IN (?)', seasons).
      order("season DESC,display_order,price DESC")
  end

  def self.bundles_available_to(customer = Customer.walkup_customer, promo_code=nil)
    bundles = ValidVoucher.
                where('? BETWEEN start_sales AND end_sales', Time.current).
                includes(:vouchertype,:showdate).references(:vouchertypes).
                where('vouchertypes.category' => 'bundle').
                order("season DESC,display_order,price DESC")
    bundles = bundles.map do |b|
      b.customer = customer
      b.supplied_promo_code = promo_code
      b.adjust_for_customer
    end
    bundles.reject! { |b| b.max_sales_for_this_patron == 0 }
    bundles.sort_by(&:display_order)
  end

  # returns a copy of this ValidVoucher, but with max_sales_for_this_patron adjusted to
  # the number of tickets of THIS vouchertype for THIS show available to
  # THIS customer.
  def adjust_for_customer
    result = self.clone_with_id
    # boxoffice and higher privilege can do anything
    result.adjust_for_visibility ||
      result.adjust_for_showdate ||
      result.adjust_for_sales_dates ||
      result.adjust_for_capacity # this one must be called last
    result.freeze
  end

  # returns a copy of this ValidVoucher for a voucher *that the customer already has*
  #  but adjusted to see if it can be redeemed
  def adjust_for_customer_reservation
    result = self.clone_with_id
    # boxoffice and higher privilege can do anything
    result.adjust_for_showdate ||
      result.adjust_for_advance_reservations ||
      result.adjust_for_capacity # this one must be called last
    result.freeze
  end

  #  This display helper is called to display menus visible to patron,
  #  so the valid-voucher in question has had its max_sales_for_this_patron ADJUSTED ALREADY
  #  to the value applicable for THIS PATRON, which may be DIFFERENT from the value
  #  specified for the valid-voucher's max_sales_for_type originally.
  def name_with_explanation
    showdate.printable_name << with_explanation
  end

  def date_with_explanation
    showdate.printable_date_with_description << with_explanation
  end

  def with_explanation
    max_sales_for_this_patron.zero? ? " (Not available)" : ""
  end
  
  def explanation_for_admin
    m = max_sales_for_this_patron 
    if  m > 0
      "#{m} available"
    else
      "Not available for this patron"
    end
  end

  def date_with_explanation_for_admin
    "#{showdate.printable_date_with_description} (#{explanation_for_admin})"
  end

  def name_with_explanation_for_admin
    "#{showdate.printable_name} (#{explanation_for_admin})"
  end

  def show_name_with_seats_of_type_remaining
    "#{showdate.printable_name} (#{seats_of_type_remaining} left)"
  end

  def show_name_with_vouchertype_name
    "#{showdate.printable_name} - #{vouchertype.name}"
  end

  def instantiate(quantity)
    raise InvalidProcessedByError unless customer.kind_of?(Customer)
    vouchers = VoucherInstantiator.new(vouchertype,:promo_code => self.promo_code).from_vouchertype(quantity)
    # if vouchertype was a bundle, check whether any of its components
    #   are monogamous, if so reserve them
    if vouchertype.bundle?
      try_reserve_for_unique(vouchers)
      # if the original vouchertype was NOT a bundle, we have a bunch of regular vouchers.
      #   if a showdate was given OR the vouchers are monogamous, reserve them.
    elsif (theshowdate = self.showdate || vouchertype.unique_showdate)
      try_reserve_for(vouchers, theshowdate)
    end
    vouchers
  end

  def try_reserve_for_unique(vouchers)
    vouchers.each do |v|
      v.reserve_for(showdate, customer) if (showdate = v.unique_showdate)
    end
  end

  def try_reserve_for(vouchers, showdate)
    vouchers.each do |v|
      v.reserve_for(showdate, customer)
    end
  end

end