core/app/models/spree/order.rb
require_dependency 'spree/order/checkout'
require_dependency 'spree/order/currency_updater'
require_dependency 'spree/order/digital'
require_dependency 'spree/order/payments'
require_dependency 'spree/order/store_credit'
require_dependency 'spree/order/emails'
module Spree
class Order < Spree::Base
PAYMENT_STATES = %w(balance_due credit_owed failed paid void)
SHIPMENT_STATES = %w(backorder canceled partial pending ready shipped)
include Spree::Order::Checkout
include Spree::Order::CurrencyUpdater
include Spree::Order::Digital
include Spree::Order::Payments
include Spree::Order::StoreCredit
include Spree::Order::AddressBook
include Spree::Order::Emails
include Spree::Core::NumberGenerator.new(prefix: 'R')
include Spree::Core::TokenGenerator
include Spree::NumberIdentifier
include Spree::NumberAsParam
include Spree::SingleStoreResource
include Spree::MemoizedData
include Spree::Metadata
if defined?(Spree::Webhooks::HasWebhooks)
include Spree::Webhooks::HasWebhooks
end
if defined?(Spree::Security::Orders)
include Spree::Security::Orders
end
if defined?(Spree::VendorConcern)
include Spree::VendorConcern
end
MEMOIZED_METHODS = %w(tax_zone)
extend Spree::DisplayMoney
money_methods :outstanding_balance, :item_total, :adjustment_total,
:included_tax_total, :additional_tax_total, :tax_total,
:shipment_total, :promo_total, :total,
:cart_promo_total, :pre_tax_item_amount, :pre_tax_total
alias display_ship_total display_shipment_total
alias_attribute :ship_total, :shipment_total
MONEY_THRESHOLD = 100_000_000
MONEY_VALIDATION = {
presence: true,
numericality: {
greater_than: -MONEY_THRESHOLD,
less_than: MONEY_THRESHOLD,
allow_blank: true
},
format: { with: /\A-?\d+(?:\.\d{1,2})?\z/, allow_blank: true }
}.freeze
POSITIVE_MONEY_VALIDATION = MONEY_VALIDATION.deep_dup.tap do |validation|
validation.fetch(:numericality)[:greater_than_or_equal_to] = 0
end.freeze
NEGATIVE_MONEY_VALIDATION = MONEY_VALIDATION.deep_dup.tap do |validation|
validation.fetch(:numericality)[:less_than_or_equal_to] = 0
end.freeze
checkout_flow do
go_to_state :address
go_to_state :delivery, if: ->(order) { order.delivery_required? }
go_to_state :payment, if: ->(order) { order.payment? || order.payment_required? }
go_to_state :confirm, if: ->(order) { order.confirmation_required? }
go_to_state :complete
remove_transition from: :delivery, to: :confirm, unless: ->(order) { order.confirmation_required? }
end
self.whitelisted_ransackable_associations = %w[shipments user created_by approver canceler promotions bill_address ship_address line_items store]
self.whitelisted_ransackable_attributes = %w[
completed_at email number state payment_state shipment_state
total item_total considered_risky channel
]
attr_reader :coupon_code
attr_accessor :temporary_address, :temporary_credit_card
attribute :state_machine_resumed, :boolean
if Spree.user_class
belongs_to :user, class_name: "::#{Spree.user_class}", optional: true
else
belongs_to :user, optional: true
end
if Spree.admin_user_class
belongs_to :created_by, class_name: Spree.admin_user_class.to_s, optional: true
belongs_to :approver, class_name: Spree.admin_user_class.to_s, optional: true
belongs_to :canceler, class_name: Spree.admin_user_class.to_s, optional: true
else
belongs_to :created_by, optional: true
belongs_to :approver, optional: true
belongs_to :canceler, optional: true
end
belongs_to :bill_address, foreign_key: :bill_address_id, class_name: 'Spree::Address',
optional: true, dependent: :destroy
alias_attribute :billing_address, :bill_address
belongs_to :ship_address, foreign_key: :ship_address_id, class_name: 'Spree::Address',
optional: true, dependent: :destroy
alias_attribute :shipping_address, :ship_address
belongs_to :store, class_name: 'Spree::Store'
with_options dependent: :destroy do
has_many :state_changes, as: :stateful, class_name: 'Spree::StateChange'
has_many :line_items, -> { order(:created_at) }, inverse_of: :order, class_name: 'Spree::LineItem'
has_many :payments, class_name: 'Spree::Payment'
has_many :return_authorizations, inverse_of: :order, class_name: 'Spree::ReturnAuthorization'
has_many :adjustments, -> { order(:created_at) }, as: :adjustable, class_name: 'Spree::Adjustment'
end
has_many :reimbursements, inverse_of: :order, class_name: 'Spree::Reimbursement'
has_many :line_item_adjustments, through: :line_items, source: :adjustments
has_many :inventory_units, inverse_of: :order, class_name: 'Spree::InventoryUnit'
has_many :return_items, through: :inventory_units, class_name: 'Spree::ReturnItem'
has_many :variants, through: :line_items
has_many :products, through: :variants
has_many :refunds, through: :payments
has_many :all_adjustments,
class_name: 'Spree::Adjustment',
foreign_key: :order_id,
dependent: :destroy,
inverse_of: :order
has_many :order_promotions, class_name: 'Spree::OrderPromotion'
has_many :promotions, through: :order_promotions, class_name: 'Spree::Promotion'
has_many :shipments, class_name: 'Spree::Shipment', dependent: :destroy, inverse_of: :order do
def states
pluck(:state).uniq
end
end
has_many :shipment_adjustments, through: :shipments, source: :adjustments
accepts_nested_attributes_for :line_items
accepts_nested_attributes_for :bill_address
accepts_nested_attributes_for :ship_address
accepts_nested_attributes_for :payments, reject_if: :credit_card_nil_payment?
accepts_nested_attributes_for :shipments
# Needs to happen before save_permalink is called
before_validation :ensure_store_presence
before_validation :ensure_currency_presence
before_validation :clone_billing_address, if: :use_billing?
attr_accessor :use_billing
before_create :create_token
before_create :link_by_email
before_update :ensure_updated_shipments, :homogenize_line_item_currencies, if: :currency_changed?
with_options presence: true do
# we want to have this case_sentive: true as changing it to false causes all SQL to use LOWER(slug)
# which is very costly and slow on large set of records
validates :email, length: { maximum: 254, allow_blank: true }, email: { allow_blank: true }, if: :require_email
validates :item_count, numericality: { greater_than_or_equal_to: 0, less_than: 2**31, only_integer: true, allow_blank: true }
validates :store
validates :currency
end
validates :payment_state, inclusion: { in: PAYMENT_STATES, allow_blank: true }
validates :shipment_state, inclusion: { in: SHIPMENT_STATES, allow_blank: true }
validates :item_total, POSITIVE_MONEY_VALIDATION
validates :adjustment_total, MONEY_VALIDATION
validates :included_tax_total, POSITIVE_MONEY_VALIDATION
validates :additional_tax_total, POSITIVE_MONEY_VALIDATION
validates :payment_total, MONEY_VALIDATION
validates :shipment_total, MONEY_VALIDATION
validates :promo_total, NEGATIVE_MONEY_VALIDATION
validates :total, MONEY_VALIDATION
delegate :update_totals, :persist_totals, to: :updater
delegate :merge!, to: :merger
delegate :firstname, :lastname, to: :bill_address, prefix: true, allow_nil: true
class_attribute :update_hooks
self.update_hooks = Set.new
scope :created_between, ->(start_date, end_date) { where(created_at: start_date..end_date) }
scope :completed_between, ->(start_date, end_date) { where(completed_at: start_date..end_date) }
scope :complete, -> { where.not(completed_at: nil) }
scope :incomplete, -> { where(completed_at: nil) }
scope :not_canceled, -> { where.not(state: 'canceled') }
scope :with_deleted_bill_address, -> { joins(:bill_address).where.not(Address.table_name => { deleted_at: nil }) }
scope :with_deleted_ship_address, -> { joins(:ship_address).where.not(Address.table_name => { deleted_at: nil }) }
# shows completed orders first, by their completed_at date, then uncompleted orders by their created_at
scope :reverse_chronological, -> { order(Arel.sql('spree_orders.completed_at IS NULL'), completed_at: :desc, created_at: :desc) }
# Use this method in other gems that wish to register their own custom logic
# that should be called after Order#update
def self.register_update_hook(hook)
update_hooks.add(hook)
end
# For compatibility with Calculator::PriceSack
def amount
line_items.inject(0.0) { |sum, li| sum + li.amount }
end
# Sum of all line item amounts pre-tax
def pre_tax_item_amount
line_items.sum(:pre_tax_amount)
end
# Sum of all line item and shipment pre-tax
def pre_tax_total
pre_tax_item_amount + shipments.sum(:pre_tax_amount)
end
def shipping_discount
shipment_adjustments.non_tax.eligible.sum(:amount) * - 1
end
def completed?
completed_at.present?
end
# Indicates whether or not the user is allowed to proceed to checkout.
# Currently this is implemented as a check for whether or not there is at
# least one LineItem in the Order. Feel free to override this logic in your
# own application if you require additional steps before allowing a checkout.
def checkout_allowed?
line_items.exists?
end
# Does this order require a physical delivery.
def delivery_required?
!digital?
end
# Is this a free order in which case the payment step should be skipped
def payment_required?
total.to_f > 0.0
end
# If true, causes the confirmation step to happen during the checkout process
def confirmation_required?
Spree::Config[:always_include_confirm_step] ||
payments.valid.map(&:payment_method).compact.any?(&:payment_profiles_supported?) ||
# Little hacky fix for #4117
# If this wasn't here, order would transition to address state on confirm failure
# because there would be no valid payments any more.
confirm?
end
def backordered?
shipments.any?(&:backordered?)
end
# Returns the relevant zone (if any) to be used for taxation purposes.
# Uses default tax zone unless there is a specific match
def tax_zone
@tax_zone ||= Zone.match(tax_address) || Zone.default_tax
end
# Returns the address for taxation based on configuration
def tax_address
Spree::Config[:tax_using_ship_address] ? ship_address : bill_address
end
def updater
@updater ||= OrderUpdater.new(self)
end
def update_with_updater!
updater.update
end
def merger
@merger ||= Spree::OrderMerger.new(self)
end
def ensure_store_presence
self.store ||= Spree::Store.default
end
def allow_cancel?
return false if !completed? || canceled?
shipment_state.nil? || %w{ready backorder pending}.include?(shipment_state)
end
def all_inventory_units_returned?
inventory_units.all?(&:returned?)
end
# Associates the specified user with the order.
def associate_user!(user, override_email = true)
self.user = user
self.email = user.email if override_email
self.created_by ||= user
self.bill_address ||= user.bill_address.try(:clone)
self.ship_address ||= user.ship_address.try(:clone)
changes = slice(:user_id, :email, :created_by_id, :bill_address_id, :ship_address_id)
# immediately persist the changes we just made, but don't use save
# since we might have an invalid address associated
self.class.unscoped.where(id: self).update_all(changes)
end
def quantity_of(variant, options = {})
line_item = find_line_item_by_variant(variant, options)
line_item ? line_item.quantity : 0
end
def find_line_item_by_variant(variant, options = {})
line_items.detect do |line_item|
line_item.variant_id == variant.id &&
Spree::Dependencies.cart_compare_line_items_service.constantize.new.call(order: self, line_item: line_item, options: options).value
end
end
# Creates new tax charges if there are any applicable rates. If prices already
# include taxes then price adjustments are created instead.
def create_tax_charge!
Spree::TaxRate.adjust(self, line_items)
Spree::TaxRate.adjust(self, shipments) if shipments.any?
end
def create_shipment_tax_charge!
Spree::TaxRate.adjust(self, shipments) if shipments.any?
end
def update_line_item_prices!
transaction do
line_items.reload.each(&:update_price)
save!
end
end
def outstanding_balance
if canceled?
-1 * payment_total
else
total - (payment_total + reimbursement_paid_total)
end
end
def reimbursement_paid_total
reimbursements.sum(&:paid_amount)
end
def outstanding_balance?
outstanding_balance != 0
end
def name
if (address = bill_address || ship_address)
address.full_name
end
end
def can_ship?
complete? || resumed? || awaiting_return? || returned?
end
def uneditable?
complete? || canceled? || returned?
end
def credit_cards
credit_card_ids = payments.from_credit_card.pluck(:source_id).uniq
CreditCard.where(id: credit_card_ids)
end
def valid_credit_cards
credit_card_ids = payments.from_credit_card.valid.pluck(:source_id).uniq
CreditCard.where(id: credit_card_ids)
end
# Finalizes an in progress order after checkout is complete.
# Called after transition to complete state when payments will have been processed
def finalize!
# lock all adjustments (coupon promotions, etc.)
all_adjustments.each(&:close)
# update payment and shipment(s) states, and save
updater.update_payment_state
shipments.each do |shipment|
shipment.update!(self)
shipment.finalize!
end
updater.update_shipment_state
save!
updater.run_hooks
touch :completed_at
deliver_order_confirmation_email unless confirmation_delivered?
deliver_store_owner_order_notification_email if deliver_store_owner_order_notification_email?
consider_risk
end
def fulfill!
shipments.each { |shipment| shipment.update!(self) if shipment.persisted? }
updater.update_shipment_state
save!
end
# Helper methods for checkout steps
def paid?
payments.valid.completed.size == payments.valid.size && payments.valid.sum(:amount) >= total
end
def available_payment_methods(store = nil)
if store.present?
Spree::Deprecation.warn('The `store` parameter is deprecated and will be removed in Spree 5. Order is already associated with Store')
end
@available_payment_methods ||= collect_payment_methods(store)
end
def insufficient_stock_lines
line_items.select(&:insufficient_stock?)
end
##
# Check to see if any line item variants are discontinued.
# If so add error and restart checkout.
def ensure_line_item_variants_are_not_discontinued
if line_items.any? { |li| !li.variant || li.variant.discontinued? }
restart_checkout_flow
errors.add(:base, Spree.t(:discontinued_variants_present))
false
else
true
end
end
def ensure_line_items_are_in_stock
if insufficient_stock_lines.present?
restart_checkout_flow
errors.add(:base, Spree.t(:insufficient_stock_lines_present))
false
else
true
end
end
def empty!
Spree::Deprecation.warn(<<-DEPRECATION, caller)
`Order#empty!` is deprecated and will be removed in Spree 5.0.
Please use `Spree::Cart::Empty.call(order: order)` instead.
DEPRECATION
raise Spree.t(:cannot_empty_completed_order) if completed?
result = Spree::Dependencies.cart_empty_service.constantize.call(order: self)
result.value
end
def has_step?(step)
checkout_steps.include?(step)
end
def state_changed(name)
state = "#{name}_state"
if persisted?
old_state = send("#{state}_was")
new_state = send(state)
unless old_state == new_state
log_state_changes(state_name: name, old_state: old_state, new_state: new_state)
end
end
end
def log_state_changes(state_name:, old_state:, new_state:)
state_changes.create(
previous_state: old_state,
next_state: new_state,
name: state_name,
user_id: user_id
)
end
def coupon_code=(code)
@coupon_code = begin
code.strip.downcase
rescue StandardError
nil
end
end
def can_add_coupon?
Spree::Promotion.order_activatable?(self)
end
def shipped?
%w(partial shipped).include?(shipment_state)
end
def fully_shipped?
shipments.shipped.size == shipments.size
end
def create_proposed_shipments
all_adjustments.shipping.delete_all
shipment_ids = shipments.map(&:id)
StateChange.where(stateful_type: 'Spree::Shipment', stateful_id: shipment_ids).delete_all
ShippingRate.where(shipment_id: shipment_ids).delete_all
shipments.delete_all
# Inventory Units which are not associated to any shipment (unshippable)
# and are not returned or shipped should be deleted
inventory_units.on_hand_or_backordered.delete_all
self.shipments = Spree::Stock::Coordinator.new(self).shipments
end
def apply_free_shipping_promotions
Spree::PromotionHandler::FreeShipping.new(self).activate
shipments.each { |shipment| Spree::Adjustable::AdjustmentsUpdater.update(shipment) }
create_shipment_tax_charge!
update_with_updater!
end
# Applies user promotions when login after filling the cart
def apply_unassigned_promotions
::Spree::PromotionHandler::Cart.new(self).activate
end
# Clean shipments and make order back to address state
#
# At some point the might need to force the order to transition from address
# to delivery again so that proper updated shipments are created.
# e.g. customer goes back from payment step and changes order items
def ensure_updated_shipments
if shipments.any? && !completed?
shipments.destroy_all
update_column(:shipment_total, 0)
restart_checkout_flow
end
end
def restart_checkout_flow
update_columns(
state: 'cart',
updated_at: Time.current
)
next! unless line_items.empty?
end
def refresh_shipment_rates(shipping_method_filter = ShippingMethod::DISPLAY_ON_FRONT_END)
shipments.map { |s| s.refresh_rates(shipping_method_filter) }
end
def shipping_eq_billing_address?
bill_address == ship_address
end
def set_shipments_cost
shipments.each(&:update_amounts)
updater.update_shipment_total
persist_totals
end
def is_risky?
!payments.risky.empty?
end
def canceled_by(user)
transaction do
cancel!
update_columns(
canceler_id: user.id,
canceled_at: Time.current
)
end
end
def approved_by(user)
transaction do
approve!
update_columns(
approver_id: user.id,
approved_at: Time.current
)
end
end
def approved?
!!approved_at
end
def can_approve?
!approved?
end
def can_be_destroyed?
!completed? && payments.completed.empty?
end
def consider_risk
considered_risky! if is_risky? && !approved?
end
def considered_risky!
update_column(:considered_risky, true)
end
def approve!
update_column(:considered_risky, false)
end
def tax_total
included_tax_total + additional_tax_total
end
def quantity
line_items.sum(:quantity)
end
def has_non_reimbursement_related_refunds?
refunds.non_reimbursement.exists? ||
payments.offset_payment.exists? # how old versions of spree stored refunds
end
def collect_backend_payment_methods
PaymentMethod.available_on_back_end.select { |pm| pm.available_for_order?(self) }
end
# determines whether the inventory is fully discounted
#
# Returns
# - true if inventory amount is the exact negative of inventory related adjustments
# - false otherwise
def fully_discounted?
adjustment_total + line_items.map(&:final_amount).sum == 0.0
end
alias fully_discounted fully_discounted?
def promo_code
promotions.pluck(:code).compact.first
end
def validate_payments_attributes(attributes)
Spree::Deprecation.warn('`Order#validate_payments_attributes` is deprecated and will be removed in Spree 5')
# Ensure the payment methods specified are allowed for this user
payment_method_ids = available_payment_methods.map(&:id).map(&:to_s)
attributes.each do |payment_attributes|
payment_method_id = payment_attributes[:payment_method_id].to_s
raise ActiveRecord::RecordNotFound unless payment_method_ids.include?(payment_method_id)
end
end
def valid_promotions
order_promotions.where(promotion_id: valid_promotion_ids).uniq(&:promotion_id)
end
def valid_promotion_ids
all_adjustments.eligible.nonzero.promotion.map { |a| a.source.promotion_id }.uniq
end
def valid_coupon_promotions
promotions.
where(id: valid_promotion_ids).
coupons
end
# Returns item and whole order discount amount for Order
# without Shipment disccounts (eg. Free Shipping)
# @return [BigDecimal]
def cart_promo_total
all_adjustments.eligible.nonzero.promotion.
where.not(adjustable_type: 'Spree::Shipment').
sum(:amount)
end
def has_free_shipping?
shipment_adjustments.
joins(:promotion_action).
where(spree_adjustments: { eligible: true, source_type: 'Spree::PromotionAction' },
spree_promotion_actions: { type: 'Spree::Promotion::Actions::FreeShipping' }).exists?
end
private
def link_by_email
self.email = user.email if user
end
# Determine if email is required (we don't want validation errors before we hit the checkout)
def require_email
true unless new_record? || ['cart', 'address'].include?(state)
end
def ensure_line_items_present
unless line_items.present?
errors.add(:base, Spree.t(:there_are_no_items_for_this_order)) && (return false)
end
end
def ensure_available_shipping_rates
if shipments.empty? || shipments.any? { |shipment| shipment.shipping_rates.blank? }
# After this point, order redirects back to 'address' state and asks user to pick a proper address
# Therefore, shipments are not necessary at this point.
shipments.destroy_all
errors.add(:base, Spree.t(:items_cannot_be_shipped)) && (return false)
end
end
def after_cancel
shipments.each(&:cancel!)
payments.completed.each(&:cancel!)
# Free up authorized store credits
payments.store_credits.pending.each(&:void!)
send_cancel_email
update_with_updater!
end
def after_resume
shipments.each(&:resume!)
consider_risk
end
def use_billing?
use_billing.in?([true, 'true', '1'])
end
def ensure_currency_presence
self.currency ||= store.default_currency
end
def create_token
self.token ||= generate_token
end
def collect_payment_methods(store = nil)
if store.present?
Spree::Deprecation.warn('The `store` parameter is deprecated and will be removed in Spree 5. Order is already associated with Store')
end
store ||= self.store
store.payment_methods.available_on_front_end.select { |pm| pm.available_for_order?(self) }
end
def credit_card_nil_payment?(attributes)
payments.store_credits.present? && attributes[:amount].to_f.zero?
end
end
end