# When an Order is first initialized it is done in the pending state # - when it's in the pending state, none of the buyer entered information is required # - when a pending order is rendered: # - if the user has a billing address, go to step 2 # - if the user has no billing address, go to step 1 # # After Step1, we go to the confirmed state # After Step2, we are in the purchased or declined state module Effective class Order < ActiveRecord::Base self.table_name = EffectiveOrders.orders_table_name.to_s if EffectiveOrders.obfuscate_order_ids raise('unsupported obfuscation with tenant') if defined?(Tenant) acts_as_obfuscated format: '###-####-###' end acts_as_addressable(billing: { singular: true }, shipping: { singular: true }) attr_accessor :terms_and_conditions # Yes, I agree to the terms and conditions attr_accessor :confirmed_checkout # Set on the Checkout Step 1 # Settings in the /admin action forms attr_accessor :send_payment_request_to_buyer # Set by Admin::Orders#new. Should the payment request email be sent after creating an order? attr_accessor :send_mark_as_paid_email_to_buyer # Set by Admin::Orders#mark_as_paid attr_accessor :skip_buyer_validations # Set by Admin::Orders#create # If we want to use orders in a has_many way belongs_to :parent, polymorphic: true, optional: true belongs_to :user, polymorphic: true, validate: false # This is the buyer/user of the order. We validate it below. accepts_nested_attributes_for :user, allow_destroy: false, update_only: true has_many :order_items, -> { order(:id) }, inverse_of: :order, dependent: :delete_all accepts_nested_attributes_for :order_items, allow_destroy: false, reject_if: :all_blank # Attributes effective_resource do state :string purchased_at :datetime note :text # From buyer to admin note_to_buyer :text # From admin to buyer note_internal :text # Internal admin only billing_name :string # name of buyer email :string # same as user.email cc :string # can be set by admin payment :text # serialized hash containing all the payment details. payment_provider :string payment_card :string tax_rate :decimal, precision: 6, scale: 3 subtotal :integer tax :integer total :integer timestamps end serialize :payment, Hash scope :deep, -> { includes(:addresses, :user, order_items: :purchasable) } scope :sorted, -> { order(:id) } scope :purchased, -> { where(state: EffectiveOrders::PURCHASED) } scope :purchased_by, lambda { |user| purchased.where(user: user) } scope :not_purchased, -> { where.not(state: EffectiveOrders::PURCHASED) } scope :pending, -> { where(state: EffectiveOrders::PENDING) } scope :confirmed, -> { where(state: EffectiveOrders::CONFIRMED) } scope :deferred, -> { where(state: EffectiveOrders::DEFERRED) } scope :declined, -> { where(state: EffectiveOrders::DECLINED) } scope :abandoned, -> { where(state: EffectiveOrders::ABANDONED) } scope :refunds, -> { purchased.where('total < ?', 0) } before_validation do self.state ||= EffectiveOrders::PENDING self.state = EffectiveOrders::CONFIRMED if pending? && confirmed_checkout end with_options(unless: -> { purchased? }) do before_validation { assign_email } before_validation { assign_user_address } before_validation { assign_billing_name } before_validation { assign_order_totals } end # Order validations validates :user_id, presence: true validates :email, presence: true, email: true # email and cc validators are from effective_resources validates :cc, email_cc: true validates :order_items, presence: { message: 'No items are present. Please add additional items.' } validates :state, inclusion: { in: EffectiveOrders::STATES.keys } # Price validations validates :subtotal, presence: true with_options(if: -> { EffectiveOrders.minimum_charge.to_i > 0 }) do validates :total, presence: true validate(unless: -> { (free? && EffectiveOrders.free?) || (refund? && EffectiveOrders.refund?) }) do if total.present? && total < EffectiveOrders.minimum_charge self.errors.add(:total, "must be $#{'%0.2f' % (EffectiveOrders.minimum_charge.to_i / 100.0)} or more. Please add additional items.") end end end validate(if: -> { tax_rate.present? }) do if (tax_rate > 100.0 || (tax_rate < 0.25 && tax_rate > 0.0000)) errors.add(:tax_rate, "is invalid. expected a value between 100.0 (100%) and 0.25 (0.25%) or 0") end end # User validations -- An admin skips these when working in the admin/ namespace with_options unless: -> { pending? || skip_buyer_validations? || purchased? } do validates :tax_rate, presence: { message: "can't be determined based on billing address" } validates :tax, presence: true validates :billing_address, presence: true, if: -> { EffectiveOrders.billing_address } validates :shipping_address, presence: true, if: -> { EffectiveOrders.shipping_address } validates :note, presence: true, if: -> { EffectiveOrders.collect_note_required } end # When Purchased with_options if: -> { purchased? } do validates :purchased_at, presence: true validates :payment, presence: true validates :payment_provider, presence: true validates :payment_card, presence: true end with_options if: -> { deferred? } do validates :payment_provider, presence: true validate do self.errors.add(:payment_provider, "unknown deferred payment provider") unless EffectiveOrders.deferred_providers.include?(payment_provider) end end before_save(if: -> { state_was == EffectiveOrders::PURCHASED }) do raise EffectiveOrders::AlreadyPurchasedException.new('cannot unpurchase an order') unless purchased? end # Effective::Order.new() # Effective::Order.new(Product.first) # Effective::Order.new(current_cart) # Effective::Order.new(Effective::Order.last) # Effective::Order.new(items: Product.first) # Effective::Order.new(items: [Product.first, Product.second], user: User.first) # Effective::Order.new(items: Product.first, user: User.first, billing_address: Effective::Address.new, shipping_address: Effective::Address.new) def initialize(atts = nil, &block) super(state: EffectiveOrders::PENDING) # Initialize with state: PENDING return self unless atts.present? if atts.kind_of?(Hash) items = Array(atts[:item]) + Array(atts[:items]) self.user = atts[:user] || items.first.try(:user) if (address = atts[:billing_address]).present? self.billing_address = address self.billing_address.full_name ||= user.to_s.presence end if (address = atts[:shipping_address]).present? self.shipping_address = address self.shipping_address.full_name ||= user.to_s.presence end atts.except(:item, :items, :user, :billing_address, :shipping_address).each do |key, value| self.send("#{key}=", value) end add(items) if items.present? else # Attributes are not a Hash self.user = atts.user if atts.respond_to?(:user) add(atts) end self end def remove(*items) raise 'unable to alter a purchased order' if purchased? raise 'unable to alter a declined order' if declined? removed = items.map do |item| order_item = if item.kind_of?(Effective::OrderItem) order_items.find { |oi| oi == item } else order_items.find { |oi| oi.purchasable == item } end raise("Unable to find order item for #{item}") if order_item.blank? order_item end removed.each { |order_item| order_item.mark_for_destruction } # Make sure to reset stored aggregates self.total = nil self.subtotal = nil self.tax = nil removed.length == 1 ? removed.first : removed end # Items can be an Effective::Cart, an Effective::order, a single acts_as_purchasable, or multiple acts_as_purchasables # add(Product.first) => returns an Effective::OrderItem # add(Product.first, current_cart) => returns an array of Effective::OrderItems def add(*items, quantity: 1) raise 'unable to alter a purchased order' if purchased? raise 'unable to alter a declined order' if declined? cart_items = items.flatten.flat_map do |item| if item.kind_of?(Effective::Cart) item.cart_items.to_a elsif item.kind_of?(ActsAsPurchasable) Effective::CartItem.new(quantity: quantity, purchasable: item) elsif item.kind_of?(Effective::Order) # Duplicate an existing order self.note_to_buyer ||= item.note_to_buyer self.note_internal ||= item.note_internal self.cc ||= item.cc item.order_items.select { |oi| oi.purchasable.kind_of?(Effective::Product) }.map do |oi| purchasable = oi.purchasable product = Effective::Product.new(name: purchasable.purchasable_name, price: purchasable.price, tax_exempt: purchasable.tax_exempt) # Copy over any extended attributes that may have been created atts = purchasable.dup.attributes.except('name', 'price', 'tax_exempt', 'purchased_order_id').compact atts.each do |k, v| next unless product.respond_to?("#{k}=") && product.respond_to?(k) product.send("#{k}=", v) if product.send(k).blank? end Effective::CartItem.new(quantity: oi.quantity, purchasable: product) end else raise 'add() expects one or more acts_as_purchasable objects, or an Effective::Cart' end end.compact # Make sure to reset stored aggregates self.total = nil self.subtotal = nil self.tax = nil retval = cart_items.map do |item| order_items.build( name: item.name, quantity: item.quantity, price: item.price, tax_exempt: (item.tax_exempt || false), ).tap { |order_item| order_item.purchasable = item.purchasable } end retval.size == 1 ? retval.first : retval end def update_prices! raise('already purchased') if purchased? raise('must be pending or confirmed') unless pending? || confirmed? present_order_items.each do |item| purchasable = item.purchasable if purchasable.blank? || purchasable.marked_for_destruction? item.mark_for_destruction else item.assign_purchasable_attributes end end save! end def to_s [label, ' #', to_param].join end def label if refund? && purchased? 'Refund' elsif purchased? 'Receipt' elsif refund? && (pending? || confirmed?) 'Pending Refund' elsif (pending? || confirmed?) 'Pending Order' else 'Order' end end def total_label if refund? && purchased? 'Total Paid' elsif purchased? 'Total Paid' elsif refund? && (pending? || confirmed?) 'Total Due' elsif (pending? || confirmed?) 'Total Due' else 'Total' end end # Visa - 1234 def payment_method return nil unless purchased? provider = payment_provider if ['cheque', 'phone'].include?(payment_provider) # Normalize payment card card = case payment_card.to_s.downcase.gsub(' ', '').strip when '' then nil when 'v', 'visa' then 'Visa' when 'm', 'mc', 'master', 'mastercard' then 'MasterCard' when 'a', 'ax', 'american', 'americanexpress' then 'American Express' when 'd', 'discover' then 'Discover' else payment_card.to_s end # Try again if card == 'none' && payment['card_type'].present? card = case payment['card_type'].to_s.downcase.gsub(' ', '').strip when '' then nil when 'v', 'visa' then 'Visa' when 'm', 'mc', 'master', 'mastercard' then 'MasterCard' when 'a', 'ax', 'american', 'americanexpress' then 'American Express' when 'd', 'discover' then 'Discover' else payment_card.to_s end end last4 = if payment[:active_card] && payment[:active_card].include?('**** **** ****') payment[:active_card][15,4] end # stripe, moneris, moneris_checkout last4 ||= (payment['f4l4'] || payment['first6last4']).to_s.last(4) [provider.presence, card.presence, last4.presence].compact.join(' - ') end def duplicate Effective::Order.new(self) end # For moneris and moneris_checkout. Just a unique value. Must be 50 characters or fewer or will raise moneris error. def transaction_id [to_param, billing_name.to_s.parameterize.first(20).presence, Time.zone.now.to_i, rand(1000..9999)].compact.join('-') end def billing_first_name billing_name.to_s.split(' ').first end def billing_last_name Array(billing_name.to_s.split(' ')[1..-1]).join(' ') end def pending? state == EffectiveOrders::PENDING end def confirmed? state == EffectiveOrders::CONFIRMED end def deferred? state == EffectiveOrders::DEFERRED end def in_progress? pending? || confirmed? || deferred? end def done? purchased? || declined? end # A custom order is one that was created by an admin # We allow custom orders to have their order items updated def custom_order? order_items.all? { |oi| oi.purchasable_type == 'Effective::Product' } end def purchased?(provider = nil) return false if (state != EffectiveOrders::PURCHASED) return true if provider.nil? || payment_provider == provider.to_s false end def declined? state == EffectiveOrders::DECLINED end def abandoned? state == EffectiveOrders::ABANDONED end def purchasables present_order_items.map { |order_item| order_item.purchasable } end def subtotal self[:subtotal] || present_order_items.map { |oi| oi.subtotal }.sum end def tax_rate self[:tax_rate] || get_tax_rate() end def tax self[:tax] || get_tax() end def total (self[:total] || (subtotal + tax.to_i)).to_i end def free? total == 0 end def refund? total.to_i < 0 end def num_items present_order_items.map { |oi| oi.quantity }.sum end def send_order_receipt_to_admin? return false if free? && !EffectiveOrders.send_order_receipts_when_free EffectiveOrders.send_order_receipt_to_admin end def send_order_receipt_to_buyer? return false if free? && !EffectiveOrders.send_order_receipts_when_free EffectiveOrders.send_order_receipt_to_buyer end def send_payment_request_to_buyer? return false if free? && !EffectiveOrders.send_order_receipts_when_free return false if refund? EffectiveResources.truthy?(send_payment_request_to_buyer) end def send_mark_as_paid_email_to_buyer? EffectiveResources.truthy?(send_mark_as_paid_email_to_buyer) end def skip_buyer_validations? EffectiveResources.truthy?(skip_buyer_validations) end # This is called from admin/orders#create # This is intended for use as an admin action only # It skips any address or bad user validations # It's basically the same as save! on a new order, except it might send the payment request to buyer def pending! return false if purchased? self.state = EffectiveOrders::PENDING self.addresses.clear if addresses.any? { |address| address.valid? == false } save! send_payment_request_to_buyer! if send_payment_request_to_buyer? true end # Used by admin checkout only def confirm! return false if purchased? update!(state: EffectiveOrders::CONFIRMED) end # This lets us skip to the confirmed workflow for an admin... def assign_confirmed_if_valid! return unless pending? self.state = EffectiveOrders::CONFIRMED return true if valid? self.errors.clear self.state = EffectiveOrders::PENDING false end # Call this as a way to skip over non consequential orders # And mark some purchasables purchased def mark_as_purchased! purchase!(skip_buyer_validations: true, email: false, skip_quickbooks: true) end # Effective::Order.new(items: Product.first, user: User.first).purchase!(email: false) def purchase!(payment: 'none', provider: 'none', card: 'none', email: true, skip_buyer_validations: false, skip_quickbooks: false) return true if purchased? # Assign attributes assign_attributes( state: EffectiveOrders::PURCHASED, skip_buyer_validations: skip_buyer_validations, payment_provider: provider, payment_card: (card.presence || 'none'), purchased_at: Time.zone.now, payment: payment_to_h(payment) ) begin Effective::Order.transaction do run_purchasable_callbacks(:before_purchase) save! update_purchasables_purchased_order! run_purchasable_callbacks(:after_purchase) end rescue => e Effective::Order.transaction do save!(validate: false) update_purchasables_purchased_order! end raise(e) end send_order_receipts! if email after_commit { sync_quickbooks!(skip: skip_quickbooks) } true end # We support two different Quickbooks synchronization gems: effective_qb_sync and effective_qb_online def sync_quickbooks!(skip:) if EffectiveOrders.qb_online? skip ? EffectiveQbOnline.skip_order!(self) : EffectiveQbOnline.sync_order!(self) end if EffectiveOrders.qb_sync? skip ? EffectiveQbSync.skip_order!(self) : true # Nothing to do end true end def skip_quickbooks! sync_quickbooks!(skip: true) end def defer!(provider: 'none', email: true) return false if purchased? assign_attributes(state: EffectiveOrders::DEFERRED, payment_provider: provider) save! send_payment_request_to_buyer! if email true end def decline!(payment: 'none', provider: 'none', card: 'none', validate: true) return false if declined? raise EffectiveOrders::AlreadyPurchasedException.new('order already purchased') if purchased? error = nil assign_attributes( state: EffectiveOrders::DECLINED, purchased_at: nil, payment: payment_to_h(payment), payment_provider: provider, payment_card: (card.presence || 'none'), skip_buyer_validations: true ) Effective::Order.transaction do begin run_purchasable_callbacks(:before_decline) save!(validate: validate) run_purchasable_callbacks(:after_decline) rescue => e self.state = state_was error = e.message raise ::ActiveRecord::Rollback end end raise "Failed to decline order: #{error || errors.full_messages.to_sentence}" unless error.nil? true end # Doesn't control anything. Purely for the flash messaging def emails_send_to [email, cc.presence].compact.to_sentence end def send_order_receipts! send_order_receipt_to_admin! if send_order_receipt_to_admin? send_order_receipt_to_buyer! if send_order_receipt_to_buyer? send_refund_notification! if refund? end def send_order_receipt_to_admin! EffectiveOrders.send_email(:order_receipt_to_admin, self) if purchased? end def send_order_receipt_to_buyer! EffectiveOrders.send_email(:order_receipt_to_buyer, self) if purchased? end def send_payment_request_to_buyer! EffectiveOrders.send_email(:payment_request_to_buyer, self) unless purchased? end def send_pending_order_invoice_to_buyer! EffectiveOrders.send_email(:pending_order_invoice_to_buyer, self) unless purchased? end def send_refund_notification! EffectiveOrders.send_email(:refund_notification_to_admin, self) if purchased? && refund? end protected def get_tax_rate rate = instance_exec(self, &EffectiveOrders.order_tax_rate_method).to_f if (rate > 100.0 || (rate < 0.25 && rate > 0.0000)) raise "expected EffectiveOrders.order_tax_rate_method to return a value between 100.0 (100%) and 0.25 (0.25%) or 0 or nil. Received #{rate}. Please return 5.25 for 5.25% tax." end rate end def get_tax return nil unless tax_rate.present? present_order_items.reject { |oi| oi.tax_exempt? }.map { |oi| (oi.subtotal * (tax_rate / 100.0)).round(0).to_i }.sum end private def present_order_items order_items.reject { |oi| oi.marked_for_destruction? } end def assign_billing_name self.billing_name ||= billing_address.try(:full_name).presence || user.to_s.presence end def assign_email self.email ||= user.try(:email) end def assign_user_address return unless user.present? if EffectiveOrders.billing_address && billing_address.blank? && user.try(:billing_address).present? self.billing_address = user.billing_address self.billing_address.full_name ||= user.to_s.presence end if EffectiveOrders.shipping_address && shipping_address.blank? && user.try(:shipping_address).present? self.shipping_address = user.shipping_address self.shipping_address.full_name ||= user.to_s.presence end end # This overwrites the prices, taxes, etc on every save. def assign_order_totals # Copies prices from purchasable into order items present_order_items.each { |oi| oi.assign_purchasable_attributes() } # The subtotal subtotal = present_order_items.map { |oi| oi.subtotal }.sum self.subtotal = subtotal self.tax_rate = get_tax_rate() self.tax = get_tax() self.total = subtotal + (tax || 0) end def update_purchasables_purchased_order! order_items.each { |oi| oi.purchasable&.update_column(:purchased_order_id, self.id) } end def run_purchasable_callbacks(name) order_items.select { |item| item.purchasable.respond_to?(name) }.each do |item| if item.class.respond_to?(:transaction) item.class.transaction { item.purchasable.public_send(name, self, item) } else item.purchasable.public_send(name, self, item) end end if parent.respond_to?(name) if parent.class.respond_to?(:transaction) parent.class.transaction { parent.public_send(name, self) } else parent.public_send(name, self) end end true end def payment_to_h(payment) if payment.respond_to?(:to_unsafe_h) payment.to_unsafe_h.to_h elsif payment.respond_to?(:to_h) payment.to_h else { details: (payment.to_s.presence || 'none') } end end end end