Permalink
Cannot retrieve contributors at this time
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
spree/core/app/models/spree/order.rb
Go to fileThis commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
759 lines (620 sloc)
24.2 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 NumberIdentifier | |
include NumberAsParam | |
include SingleStoreResource | |
include MemoizedData | |
include Spree::Metadata | |
if defined?(Spree::Webhooks) | |
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? | |
ActiveSupport::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! | |
ActiveSupport::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) | |
ActiveSupport::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? | |
ActiveSupport::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 |