app/models/order.rb
class Order < ActiveRecord::Base
belongs_to :customer
belongs_to :purchaser, :class_name => 'Customer'
belongs_to :processed_by, :class_name => 'Customer'
has_many :items, :autosave => true, :dependent => :destroy
has_many :vouchers, :autosave => true, :dependent => :destroy
has_many :donations, :autosave => true, :dependent => :destroy
has_many :retail_items, :autosave => true, :dependent => :destroy
attr_accessor :purchase_args
attr_accessor :comments
attr_reader :donation
# pending and errored states of a CC order (pending = payment has occurred but order has not
# yet been finalized; errored = payment has occurred and order NEVER got finalized, eg due
# to server timeout or other event that happens during that step)
PENDING = 'pending' # string in authorization field that marks in-process CC order
ERRORED = 'errored' # payment made, but timeout/something bad happened that prevented order finalization
# errors
class Order::CannotAddItemError < StandardError ; end
class Order::NotPersistedError < StandardError ; end
class Order::StaleOrderError < StandardError ; end
class Order::OrderFinalizeError < StandardError ; end
class Order::NotReadyError < Order::OrderFinalizeError ; end
class Order::SaveRecipientError < Order::OrderFinalizeError ; end
class Order::SavePurchaserError < Order::OrderFinalizeError ; end
class Order::PaymentFailedError < Order::OrderFinalizeError ; end
# merging customers
def self.foreign_keys_to_customer
[:customer_id, :purchaser_id, :processed_by_id]
end
serialize :valid_vouchers, Hash
serialize :donation_data, Hash
serialize :retail_items, Array
def initialize(*args)
@purchase_args = {}
super
end
after_initialize :unserialize_items
private
def unserialize_items
self.donation_data ||= {}
unless donation_data.empty?
@donation = Donation.new(:amount => donation_data[:amount], :account_code_id => donation_data[:account_code_id], :comments => donation_data[:comments])
end
self.valid_vouchers ||= {}
self.retail_items ||= []
end
def check_purchaser_info
# walkup orders only need purchaser & recipient info to point to walkup
# customer, but regular orders need full purchaser & recipient info.
if walkup?
errors.add(:base, "Walkup order requires purchaser & recipient to be walkup customer") unless
(purchaser == Customer.walkup_customer && customer == purchaser)
else
errors.add(:base, 'No purchaser information') and return unless purchaser.kind_of?(Customer)
errors.add(:base, "Purchaser information is incomplete: #{purchaser.errors.full_messages.join(', ')}") unless purchaser.valid_as_purchaser?
errors.add(:base, 'No recipient information') and return unless customer.kind_of?(Customer)
errors.add(:customer, customer.errors.as_html) if (gift? && ! customer.valid_as_gift_recipient?)
end
end
public
scope :completed, ->() { where('sold_on IS NOT NULL') }
scope :abandoned_since, ->(since) { where('sold_on IS NULL').where('updated_at < ?', since) }
scope :pending_but_paid, ->() { where(:authorization => PENDING) }
scope :for_customer_reporting, ->() {
includes(:vouchers => [:customer, :showdate,:vouchertype]).
includes(:donations => [:customer, :account_code]).
includes(:processed_by).
includes(:purchaser).
includes(:items).
includes(:customer)
}
def self.to_csv
attribs = %w(id sold_on purchaser_name purchase_medium total_price item_descriptions)
CSV.generate(:headers => true) do |csv|
csv << attribs
all.each { |o| csv << attribs.map { |att| o.send att }}
end
end
def customer_name ; customer.full_name ; end
def purchaser_name ; purchaser.full_name ; end
def purchase_medium ; Purchasemethod.get(purchasemethod).purchase_medium ; end
def self.new_from_donation(amount, account_code, donor)
order = Order.new(:purchaser => donor, :customer => donor)
order.add_donation(Donation.from_amount_and_account_code_id(amount, account_code.id))
order
end
def add_comment(arg)
self.comments ||= ''
self.comments += arg
end
def clear_contents!
self.vouchers.destroy_all
self.donation_data = {}
end
def cart_empty?
ticket_count.zero? && donation.nil? && retail_items.empty?
end
def add_nonticket_items_from_params(params)
return if params.nil? || params.empty?
params.each_pair do |vv_id,qty|
vv = ValidVoucher.find(vv_id)
qty.to_i.times { add_retail_item(RetailItem.from_vouchertype vv.vouchertype) }
end
end
def add_tickets_from_params(valid_voucher_params, customer, promo_code: '', seats: [])
return unless valid_voucher_params
seats2 = seats.dup
total_tickets = valid_voucher_params.map do |id,count|
ValidVoucher.find(id).vouchertype.reservable? ? count.to_i : 0
end.sum
saleable_seats = if (sd = ValidVoucher.find(valid_voucher_params.keys.first).showdate) then sd.saleable_seats_left else 0 end
if !seats2.empty?
# error unless number of seats matches number of requested tickets
self.errors.add(:base, I18n.translate('store.errors.not_enough_seats_selected', :tickets => total_tickets, :seats => seats2.length)) and return unless total_tickets == seats2.length
end
raise Order::NotPersistedError unless persisted?
valid_voucher_params.each_pair do |vv_id, qty|
qty = qty.to_i
next if qty.zero?
vv = ValidVoucher.find(vv_id)
vv.supplied_promo_code = promo_code.to_s
vv.customer = customer || self.processed_by || Customer.anonymous_customer
seats = seats2.slice!(0,qty)
# is this order-placer allowed to exercise this redemption?
redemption = vv.adjust_for_customer
if (vv.customer.try(:is_boxoffice) || # boxoffice can do anything
redemption.max_sales_for_this_patron >= qty) # there's enough seats for this patron's limit
add_tickets_without_capacity_checks(vv, qty, seats)
else
self.errors.add(:base, I18n.translate('store.errors.not_enough_seats', :count => saleable_seats))
end
end
end
def add_open_vouchers_without_capacity_checks(vouchertype, number)
raise Order::NotPersistedError unless persisted?
new_vouchers = VoucherInstantiator.new(vouchertype).from_vouchertype(number)
self.vouchers += new_vouchers
self.save!
end
def add_tickets_without_capacity_checks(valid_voucher, number, seats=[])
raise Order::NotPersistedError unless persisted?
new_vouchers = VoucherInstantiator.new(valid_voucher.vouchertype, :promo_code => valid_voucher.supplied_promo_code || valid_voucher.promo_code).from_vouchertype(number)
# reserve only if a specific showdate is indicated. it seems like this method
# should really take a vouchertype and showdate.
if valid_voucher.showdate
begin
new_vouchers.each_with_index do |v,i|
v.seat = seats[i] unless seats.empty?
v.reserve!(valid_voucher.showdate)
rescue ActiveRecord::RecordInvalid, Voucher::ReservationError # reservation couldn't be processed
self.errors.add(:base, v.errors.full_messages.join(', '))
v.destroy # otherwise it'll end up with no order ID and can't be reaped
end
end
end
if self.errors.empty? # all vouchers were added OK
self.vouchers += new_vouchers
self.save!
else # since order can't proceed, DESTROY all vouchers so not orphaned
new_vouchers.each { |v| v.destroy if v.persisted? }
end
end
def ticket_count ; vouchers.size ; end
def item_count ; ticket_count + (includes_donation? ? 1 : 0) + retail_items.size; end
def includes_vouchers? ; ticket_count > 0 ; end
def includes_streaming? ; vouchers.any? { |v| v.showdate.try(&:stream?) } ; end
def includes_mailable_items? ; items.any? { |v| v.vouchertype.try(:fulfillment_needed?) } ; end
def includes_enrollment? ; items.any? { |v| v.showdate.try(:event_type) == 'Class' } ; end
def includes_nonticket_item? ; items.any? { |v| v.vouchertype.try(:nonticket?) } ; end
#def includes_bundle? ; vouchers.any?(&:bundle?) ; end
#def includes_regular_vouchers? ; vouchers.any? { |v| !v.bundle? } ; end
#def includes_reserved_vouchers? ; vouchers.any?(&:reserved?) ; end
def includes_bundle? ; items.any? { |v| v.vouchertype.try(:bundle?) } ; end
def includes_regular_vouchers? ; items.any? { |v| v.kind_of?(Voucher) && !v.bundle? } ; end
def includes_reserved_vouchers? ; items.any? { |v| v.kind_of?(Voucher) && v.reserved? } ; end
def add_service_charge
if includes_enrollment?
add_retail_item RetailItem.new_service_charge_for(:classes)
elsif includes_regular_vouchers?
add_retail_item RetailItem.new_service_charge_for(:regular)
elsif includes_bundle?
add_retail_item RetailItem.new_service_charge_for(:subscription)
end
end
def reserved_seating_params
if vouchers.any? { |v| v.showdate.has_reserved_seating? }
{:showdate_id => vouchers.first.showdate_id, :num_seats => vouchers.size}
else
nil
end
end
def add_donation(d) ; self.donation = d ; end
def donation=(d)
self.donation_data[:amount] = d.amount
self.donation_data[:account_code_id] = d.account_code_id
self.donation_data[:comments] = d.comments
@donation = d
end
def includes_donation?
if completed?
items.any? { |v| v.kind_of?(Donation) }
else
!donation.nil?
end
end
def includes_bundle?
if completed?
items.any? { |v| v.kind_of?(Voucher) && v.bundle? }
else
vouchers.any? { |v| v.bundle? }
end
end
def add_retail_item(r)
raise Order::NotPersistedError unless persisted?
self.retail_items << r if r
end
def ok_for_guest_checkout?
# basically, the ONLY thing you can guest checkout for is single-ticket purchases for yourself.
! (gift? || includes_mailable_items? || includes_bundle? || includes_enrollment? || includes_nonticket_item?)
end
def total_price
if completed?
items.map(&:amount).sum
else
self.donation.try(:amount).to_f +
self.retail_items.map(&:amount).sum +
self.vouchers.sum(:amount)
end
end
def walkup_confirmation_notice
notice = []
notice << "#{'$%.02f' % donation.amount} donation" if includes_donation?
if includes_vouchers?
notice << "#{ticket_count} ticket" + (ticket_count > 1 ? 's' : '')
end
if ! retail_items.empty?
nonticket_count = retail_items.size
notice << "#{nonticket_count} retail item" + (nonticket_count > 1 ? 's' : '')
end
message = notice.join(' and ')
if total_price.zero?
message = "Issued #{message} as zero-revenue order"
else
message << " (total #{'$%.02f' % total_price})"
message << " paid by #{ActiveSupport::Inflector::humanize(purchase_medium)}"
end
message
end
def summary(separator = "\n")
summary = []
vouchers, nonvouchers = items.partition { |i| i.kind_of?(Voucher) }
vouchers.group_by { |v| [v.vouchertype, v.showdate] }.each_pair do |for_show,vouchers|
summary << "#{vouchers.count} @ #{vouchers.first.one_line_description(:suppress_seat => true)}"
end
if vouchers.any? { |v| !v.seat.blank? }
summary << "Seat#{'s' if vouchers.count > 1}: #{Voucher.seats_for(vouchers)}"
end
summary += nonvouchers.map(&:one_line_description)
summary << self.comments
summary << streaming_access_instructions if includes_streaming?
summary.join(separator)
end
def summary_for_audit_txn
summary = items.map(&:description_for_audit_txn)
summary << comments unless comments.blank?
summary << "Stripe ID #{authorization}" unless authorization.blank?
summary.join('; ')
end
def streaming_access_instructions
# this is almost certainly a Demeter violation...but not sure how to make better
vouchers.first.showdate.access_instructions
end
def collect_notes
# collect the showdate-specific (= show-specific) notes in the order
items.map(&:showdate).compact.uniq.map(&:patron_notes).compact
end
def completed? ; persisted? && !sold_on.blank? ; end
def comment_prompt
if (! includes_vouchers? || includes_bundle? || gift? || includes_streaming?) then nil
elsif includes_enrollment? then {
prompt: 'Who is attending the class?',
placeholder: "Enrollee's name"
}
else {
prompt: 'Is someone other than the purchaser picking up the tickets?',
placeholder: "If yes, attendee's name"
}
end
end
def ready_for_purchase?
errors.clear
errors.add(:base, 'Shopping cart is empty') if cart_empty?
errors.add(:base, "You must specify the enrollee's name for classes") if
includes_enrollment? && comments.blank?
check_purchaser_info unless processed_by.try(:is_boxoffice)
if Purchasemethod.valid_purchasemethod?(purchasemethod)
errors.add(:base,'Invalid credit card transaction') if
purchase_args && purchase_args[:credit_card_token].blank? &&
purchase_medium == :credit_card
errors.add(:base,'Zero amount') if
total_price.zero? && Purchasemethod.must_be_nonzero_amount(purchasemethod)
else
errors.add(:base,'No payment method specified')
end
errors.add(:base, 'No information on who processed order') unless processed_by.kind_of?(Customer)
errors.empty?
end
def finalize_with_existing_customer_id!(cid,processed_by,sold_on=Time.current)
self.customer_id = self.purchaser_id = cid
self.processed_by = processed_by
self.finalize!(sold_on)
end
def finalize_with_new_customer!(customer,processed_by,sold_on=Time.current)
customer.force_valid = true
self.customer = self.purchaser = customer
self.finalize!(sold_on)
end
def finalize!(sold_on_date = Time.current)
raise Order::NotReadyError unless ready_for_purchase?
# for credit card orders ONLY:
# mark order as Pending, run the card and rescue any errors, then finalize order.
auth = nil
self.update_attributes!(:authorization => Order::PENDING) # this is a transaction, and also sets updated_at for StaleOrderSweeper
if purchase_medium == :credit_card
unless (auth = Store::Payment.pay_with_credit_card(self))
self.update_attribute(:authorization, nil) # reset order to not-pending state
raise(Order::PaymentFailedError, self.errors.as_html)
end
end
# order is still pending, since CC has been charged but changes have not been made.
# make those changes transactionally, and mark as not-pending.
# if timeout happens anywhere in here, we will know that there is a credit card charge
# that may have happened without the corresponding order update.
begin
transaction do
self.items += vouchers
self.items += retail_items
self.items << donation if donation
self.items.each do |i|
i.assign_attributes(:finalized => true,
:sold_on => sold_on_date,
:walkup => self.walkup?,
:processed_by => self.processed_by)
# "Pickup by" comments should get copied to vouchers (only)
i.comments = self.comments if i.comments.blank? && i.kind_of?(Voucher)
end
# there is also a direct relationship Customer has-many Items, which we should get rid of...
customer.add_items(vouchers)
customer.add_items(retail_items)
purchaser.add_items([donation]) if donation
customer.save!
purchaser.save!
self.sold_on = sold_on_date
self.authorization = auth # will be nil if non-credit-card purchase
self.save! # trap any last errors before running credit card
end
rescue ValidVoucher::InvalidRedemptionError => e
# we are now outside the transaction
self.update_attributes!(:authorization => nil)
raise Order::NotReadyError, e.message
rescue Order::PaymentFailedError,RuntimeError => e
# we are now outside the transaction
self.update_attributes!(:authorization => nil)
raise e
end
end
def refundable?
completed? &&
items.any? { |i| !i.kind_of?(CanceledItem) } # in case all items were ALREADY refunded and now marked as canceled
(refundable_to_credit_card? || Purchasemethod.get(purchasemethod).refundable?)
end
def refundable_to_credit_card?
completed? && purchase_medium == :credit_card && !authorization.blank?
end
def gift?
purchaser && customer != purchaser
end
def ship_to
if gift? && ship_to_purchaser then purchaser else customer end
end
def total
# :BUG: 79120088: this should be replaceable by
# items.sum(:amount)
# when every Item's 'amount' field is correctly filled in at order time
items.map(&:amount).sum
end
def purchasemethod_description
Purchasemethod.get(purchasemethod).description
end
def item_descriptions
items.map(&:item_description).
inject(Hash.new(0)) { |h,v| h[v]+=1 ; h }.
map { |item,count| ("%3d @ #{item}" % count) }.
join("\n")
end
def summary_of_contents
if includes_vouchers? && includes_donation?
'order and donation'
elsif includes_donation?
'donation'
else
'order'
end
end
end