diff --git a/app/models/spree/inventory_unit.rb b/app/models/spree/inventory_unit.rb new file mode 100644 index 0000000000..d54dbb141f --- /dev/null +++ b/app/models/spree/inventory_unit.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Spree + class InventoryUnit < ActiveRecord::Base + belongs_to :variant, class_name: "Spree::Variant" + belongs_to :order, class_name: "Spree::Order" + belongs_to :shipment, class_name: "Spree::Shipment" + belongs_to :return_authorization, class_name: "Spree::ReturnAuthorization" + + scope :backordered, -> { where state: 'backordered' } + scope :shipped, -> { where state: 'shipped' } + scope :backordered_per_variant, ->(stock_item) do + includes(:shipment) + .where("spree_shipments.state != 'canceled'").references(:shipment) + .where(variant_id: stock_item.variant_id) + .backordered.order("#{table_name}.created_at ASC") + end + + # state machine (see http://github.com/pluginaweek/state_machine/tree/master for details) + state_machine initial: :on_hand do + event :fill_backorder do + transition to: :on_hand, from: :backordered + end + after_transition on: :fill_backorder, do: :update_order + + event :ship do + transition to: :shipped, if: :allow_ship? + end + + event :return do + transition to: :returned, from: :shipped + end + end + + # This was refactored from a simpler query because the previous implementation + # lead to issues once users tried to modify the objects returned. That's due + # to ActiveRecord `joins(shipment: :stock_location)` only return readonly + # objects + # + # Returns an array of backordered inventory units as per a given stock item + def self.backordered_for_stock_item(stock_item) + backordered_per_variant(stock_item).select do |unit| + unit.shipment.stock_location == stock_item.stock_location + end + end + + def self.finalize_units!(inventory_units) + inventory_units.map { |iu| iu.update_column(:pending, false) } + end + + def find_stock_item + Spree::StockItem.find_by(stock_location_id: shipment.stock_location_id, + variant_id: variant_id) + end + + # Remove variant default_scope `deleted_at: nil` + def variant + Spree::Variant.unscoped { super } + end + + private + + def allow_ship? + Spree::Config[:allow_backorder_shipping] || on_hand? + end + + def update_order + order.update! + end + end +end diff --git a/app/models/spree/line_item.rb b/app/models/spree/line_item.rb new file mode 100644 index 0000000000..61aa00eb06 --- /dev/null +++ b/app/models/spree/line_item.rb @@ -0,0 +1,264 @@ +# frozen_string_literal: true + +require 'open_food_network/scope_variant_to_hub' +require 'variant_units/variant_and_line_item_naming' + +module Spree + class LineItem < ActiveRecord::Base + include VariantUnits::VariantAndLineItemNaming + include LineItemBasedAdjustmentHandling + + belongs_to :order, class_name: "Spree::Order", inverse_of: :line_items + belongs_to :variant, class_name: "Spree::Variant" + belongs_to :tax_category, class_name: "Spree::TaxCategory" + + has_one :product, through: :variant + has_many :adjustments, as: :adjustable, dependent: :destroy + + has_and_belongs_to_many :option_values, join_table: 'spree_option_values_line_items', + class_name: 'Spree::OptionValue' + + before_validation :adjust_quantity + before_validation :copy_price + before_validation :copy_tax_category + + validates :variant, presence: true + validates :quantity, numericality: { + only_integer: true, + greater_than: -1, + message: Spree.t('validation.must_be_int') + } + validates :price, numericality: true + validates_with Stock::AvailabilityValidator + + before_save :update_inventory + before_save :calculate_final_weight_volume, if: :quantity_changed?, + unless: :final_weight_volume_changed? + after_save :update_order + after_save :update_units + before_destroy :update_inventory_before_destroy + after_destroy :update_order + + delegate :product, :unit_description, :display_name, to: :variant + + attr_accessor :skip_stock_check # Allows manual skipping of Stock::AvailabilityValidator + attr_accessor :target_shipment + + # -- Scopes + scope :managed_by, lambda { |user| + if user.has_spree_role?('admin') + where(nil) + else + # Find line items that are from orders distributed by the user or supplied by the user + joins(variant: :product). + joins(:order). + where('spree_orders.distributor_id IN (?) OR spree_products.supplier_id IN (?)', + user.enterprises, user.enterprises). + select('spree_line_items.*') + end + } + + scope :in_orders, lambda { |orders| + where(order_id: orders) + } + + # Find line items that are from order sorted by variant name and unit value + scope :sorted_by_name_and_unit_value, -> { + joins(variant: :product). + reorder(" + lower(spree_products.name) asc, + lower(spree_variants.display_name) asc, + spree_variants.unit_value asc") + } + + scope :from_order_cycle, lambda { |order_cycle| + joins(order: :order_cycle). + where('order_cycles.id = ?', order_cycle) + } + + # Here we are simply joining the line item to its variant and product + # We dont use joins here to avoid the default scopes, + # and with that, include deleted variants and deleted products + scope :supplied_by_any, lambda { |enterprises| + product_ids = Spree::Product.unscoped.where(supplier_id: enterprises).select(:id) + variant_ids = Spree::Variant.unscoped.where(product_id: product_ids).select(:id) + where("spree_line_items.variant_id IN (?)", variant_ids) + } + + scope :with_tax, -> { + joins(:adjustments). + where('spree_adjustments.originator_type = ?', 'Spree::TaxRate'). + select('DISTINCT spree_line_items.*') + } + + # Line items without a Spree::TaxRate-originated adjustment + scope :without_tax, -> { + joins(" + LEFT OUTER JOIN spree_adjustments + ON (spree_adjustments.adjustable_id=spree_line_items.id + AND spree_adjustments.adjustable_type = 'Spree::LineItem' + AND spree_adjustments.originator_type='Spree::TaxRate')"). + where('spree_adjustments.id IS NULL') + } + + def copy_price + return unless variant + + self.price = variant.price if price.nil? + self.cost_price = variant.cost_price if cost_price.nil? + self.currency = variant.currency if currency.nil? + end + + def copy_tax_category + return unless variant + + self.tax_category = variant.product.tax_category + end + + def amount + price * quantity + end + alias total amount + + def single_money + Spree::Money.new(price, currency: currency) + end + alias single_display_amount single_money + + def money + Spree::Money.new(amount, currency: currency) + end + alias display_total money + alias display_amount money + + def adjust_quantity + self.quantity = 0 if quantity.nil? || quantity < 0 + end + + # Here we skip stock check if skip_stock_check flag is active, + # we skip stock check if requested quantity is zero or negative, + # and we scope variants to hub and thus acivate variant overrides. + def sufficient_stock? + return true if skip_stock_check + return true if quantity <= 0 + + scoper.scope(variant) + variant.can_supply?(quantity) + end + + def insufficient_stock? + !sufficient_stock? + end + + def assign_stock_changes_to=(shipment) + @preferred_shipment = shipment + end + + # Remove product default_scope `deleted_at: nil` + def product + variant.product + end + + # This ensures that LineItems always have access to soft-deleted variants. + # In some situations, unscoped super will be nil. In these cases, + # we fetch the variant using variant_id. See issue #4946 for more details. + def variant + Spree::Variant.unscoped { super } || Spree::Variant.unscoped.find(variant_id) + end + + def cap_quantity_at_stock! + scoper.scope(variant) + return if variant.on_demand + + update!(quantity: variant.on_hand) if quantity > variant.on_hand + end + + def has_tax? + adjustments.included_tax.any? + end + + def included_tax + adjustments.included_tax.sum(&:included_tax) + end + + def tax_rates + product.tax_category.andand.tax_rates || [] + end + + def price_with_adjustments + # EnterpriseFee#create_adjustment applies adjustments on line items to their parent order, + # so line_item.adjustments returns an empty array + return 0 if quantity.zero? + + line_item_adjustments = OrderAdjustmentsFetcher.new(order).line_item_adjustments(self) + + (price + line_item_adjustments.sum(&:amount) / quantity).round(2) + end + + def single_display_amount_with_adjustments + Spree::Money.new(price_with_adjustments, currency: currency) + end + + def amount_with_adjustments + # We calculate from price_with_adjustments here rather than building our own value because + # rounding errors can produce discrepencies of $0.01. + price_with_adjustments * quantity + end + + def display_amount_with_adjustments + Spree::Money.new(amount_with_adjustments, currency: currency) + end + + def display_included_tax + Spree::Money.new(included_tax, currency: currency) + end + + def unit_value + return variant.unit_value if quantity == 0 || !final_weight_volume + + final_weight_volume / quantity + end + + def scoper + @scoper ||= OpenFoodNetwork::ScopeVariantToHub.new(order.distributor) + end + + private + + def update_inventory + return unless changed? + + scoper.scope(variant) + Spree::OrderInventory.new(order).verify(self, target_shipment) + end + + def update_order + return unless changed? || destroyed? + + # update the order totals, etc. + order.create_tax_charge! + order.update! + end + + def update_inventory_before_destroy + # This is necessary before destroying the line item + # so that update_inventory will restore stock to the variant + self.quantity = 0 + + update_inventory + + # This is necessary after updating inventory + # because update_inventory may delete the last shipment in the order + # and that makes update_order fail if we don't reload the shipments + order.shipments.reload + end + + def calculate_final_weight_volume + if final_weight_volume.present? && quantity_was > 0 + self.final_weight_volume = final_weight_volume * quantity / quantity_was + elsif variant.andand.unit_value.present? + self.final_weight_volume = variant.andand.unit_value * quantity + end + end + end +end diff --git a/app/models/spree/line_item_decorator.rb b/app/models/spree/line_item_decorator.rb deleted file mode 100644 index 558b2efc59..0000000000 --- a/app/models/spree/line_item_decorator.rb +++ /dev/null @@ -1,185 +0,0 @@ -require 'open_food_network/scope_variant_to_hub' -require 'variant_units/variant_and_line_item_naming' - -Spree::LineItem.class_eval do - include VariantUnits::VariantAndLineItemNaming - include LineItemBasedAdjustmentHandling - has_and_belongs_to_many :option_values, join_table: 'spree_option_values_line_items', class_name: 'Spree::OptionValue' - - # Redefining here to add the inverse_of option - belongs_to :order, class_name: "Spree::Order", inverse_of: :line_items - - # Allows manual skipping of Stock::AvailabilityValidator - attr_accessor :skip_stock_check - - before_save :calculate_final_weight_volume, if: :quantity_changed?, unless: :final_weight_volume_changed? - after_save :update_units - - before_destroy :update_inventory_before_destroy - - delegate :product, :unit_description, to: :variant - - # -- Scopes - scope :managed_by, lambda { |user| - if user.has_spree_role?('admin') - where(nil) - else - # Find line items that are from orders distributed by the user or supplied by the user - joins(variant: :product). - joins(:order). - where('spree_orders.distributor_id IN (?) OR spree_products.supplier_id IN (?)', user.enterprises, user.enterprises). - select('spree_line_items.*') - end - } - - scope :in_orders, lambda { |orders| - where(order_id: orders) - } - - # Find line items that are from order sorted by variant name and unit value - scope :sorted_by_name_and_unit_value, -> { - joins(variant: :product). - reorder(" - lower(spree_products.name) asc, - lower(spree_variants.display_name) asc, - spree_variants.unit_value asc") - } - - scope :from_order_cycle, lambda { |order_cycle| - joins(order: :order_cycle). - where('order_cycles.id = ?', order_cycle) - } - - # Here we are simply joining the line item to its variant and product - # We dont use joins here to avoid the default scopes, - # and with that, include deleted variants and deleted products - scope :supplied_by_any, lambda { |enterprises| - product_ids = Spree::Product.unscoped.where(supplier_id: enterprises).select(:id) - variant_ids = Spree::Variant.unscoped.where(product_id: product_ids).select(:id) - where("spree_line_items.variant_id IN (?)", variant_ids) - } - - scope :with_tax, -> { - joins(:adjustments). - where('spree_adjustments.originator_type = ?', 'Spree::TaxRate'). - select('DISTINCT spree_line_items.*') - } - - # Line items without a Spree::TaxRate-originated adjustment - scope :without_tax, -> { - joins(" - LEFT OUTER JOIN spree_adjustments - ON (spree_adjustments.adjustable_id=spree_line_items.id - AND spree_adjustments.adjustable_type = 'Spree::LineItem' - AND spree_adjustments.originator_type='Spree::TaxRate')"). - where('spree_adjustments.id IS NULL') - } - - # Overridden so that LineItems always have access to soft-deleted Variant - # attributes. In some situations, unscoped super will be nil, in these cases - # we fetch the variant using the variant_id. See isssue #4946 for more - # details - def variant - Spree::Variant.unscoped { super } || Spree::Variant.unscoped.find(variant_id) - end - - def cap_quantity_at_stock! - scoper.scope(variant) - return if variant.on_demand - - update!(quantity: variant.on_hand) if quantity > variant.on_hand - end - - def has_tax? - adjustments.included_tax.any? - end - - def included_tax - adjustments.included_tax.sum(&:included_tax) - end - - def tax_rates - product.tax_category.andand.tax_rates || [] - end - - def price_with_adjustments - # EnterpriseFee#create_adjustment applies adjustments on line items to their parent order, - # so line_item.adjustments returns an empty array - return 0 if quantity.zero? - - line_item_adjustments = OrderAdjustmentsFetcher.new(order).line_item_adjustments(self) - - (price + line_item_adjustments.sum(&:amount) / quantity).round(2) - end - - def single_display_amount_with_adjustments - Spree::Money.new(price_with_adjustments, currency: currency) - end - - def amount_with_adjustments - # We calculate from price_with_adjustments here rather than building our own value because - # rounding errors can produce discrepencies of $0.01. - price_with_adjustments * quantity - end - - def display_amount_with_adjustments - Spree::Money.new(amount_with_adjustments, currency: currency) - end - - def display_included_tax - Spree::Money.new(included_tax, currency: currency) - end - - delegate :display_name, to: :variant - - def unit_value - return variant.unit_value if quantity == 0 || !final_weight_volume - - final_weight_volume / quantity - end - - # Overrides Spree version to: - # - skip stock check if skip_stock_check flag is active - # - skip stock check if requested quantity is zero or negative - # - scope variants to hub and thus acivate variant overrides - def sufficient_stock? - return true if skip_stock_check - return true if quantity <= 0 - - scoper.scope(variant) - variant.can_supply?(quantity) - end - - def scoper - @scoper ||= OpenFoodNetwork::ScopeVariantToHub.new(order.distributor) - end - - private - - def update_inventory_with_scoping - scoper.scope(variant) - update_inventory_without_scoping - end - alias_method_chain :update_inventory, :scoping - - def update_inventory_before_destroy - # This is necessary before destroying the line item - # so that update_inventory will restore stock to the variant - self.quantity = 0 - - update_inventory - - # This is necessary after updating inventory - # because update_inventory may delete the last shipment in the order - # and that makes update_order fail if we don't reload the shipments - order.shipments.reload - end - - def calculate_final_weight_volume - if final_weight_volume.present? && quantity_was > 0 - self.final_weight_volume = final_weight_volume * quantity / quantity_was - elsif variant.andand.unit_value.present? - self.final_weight_volume = variant.andand.unit_value * quantity - end - end -end diff --git a/app/models/spree/order.rb b/app/models/spree/order.rb new file mode 100644 index 0000000000..d44455d53b --- /dev/null +++ b/app/models/spree/order.rb @@ -0,0 +1,914 @@ +# frozen_string_literal: true + +require 'spree/order/checkout' +require 'open_food_network/enterprise_fee_calculator' +require 'open_food_network/feature_toggle' +require 'open_food_network/tag_rule_applicator' +require 'concerns/order_shipment' + +ActiveSupport::Notifications + .subscribe('spree.order.contents_changed') do |_name, _start, _finish, _id, payload| + payload[:order].reload.update_distribution_charge! +end + +module Spree + class Order < ActiveRecord::Base + prepend OrderShipment + include Checkout + + checkout_flow do + go_to_state :address + go_to_state :delivery + go_to_state :payment, if: ->(order) { + order.update_totals + order.payment_required? + } + go_to_state :complete + end + + state_machine.after_transition to: :payment, do: :charge_shipping_and_payment_fees! + + state_machine.event :restart_checkout do + transition to: :cart, unless: :completed? + end + + token_resource + + belongs_to :user, class_name: Spree.user_class.to_s + belongs_to :created_by, class_name: Spree.user_class.to_s + + belongs_to :bill_address, foreign_key: :bill_address_id, class_name: 'Spree::Address' + alias_attribute :billing_address, :bill_address + + belongs_to :ship_address, foreign_key: :ship_address_id, class_name: 'Spree::Address' + alias_attribute :shipping_address, :ship_address + + has_many :state_changes, as: :stateful + has_many :line_items, -> { order('created_at ASC') }, dependent: :destroy + has_many :payments, dependent: :destroy + has_many :return_authorizations, dependent: :destroy + has_many :adjustments, -> { order "#{Spree::Adjustment.table_name}.created_at ASC" }, + as: :adjustable, + dependent: :destroy + + has_many :line_item_adjustments, through: :line_items, source: :adjustments + + has_many :shipments, dependent: :destroy do + def states + pluck(:state).uniq + end + end + + belongs_to :order_cycle + belongs_to :distributor, class_name: 'Enterprise' + belongs_to :customer + has_one :proxy_order + has_one :subscription, through: :proxy_order + + accepts_nested_attributes_for :line_items + accepts_nested_attributes_for :bill_address + accepts_nested_attributes_for :ship_address + accepts_nested_attributes_for :payments + accepts_nested_attributes_for :shipments + + delegate :admin_and_handling_total, :payment_fee, :ship_total, to: :adjustments_fetcher + + # Needs to happen before save_permalink is called + before_validation :set_currency + before_validation :generate_order_number, on: :create + before_validation :clone_billing_address, if: :use_billing? + before_validation :associate_customer, unless: :customer_id? + before_validation :ensure_customer, unless: :customer_is_valid? + + validates :customer, presence: true, if: :require_customer? + validate :products_available_from_new_distribution, if: lambda { + distributor_id_changed? || order_cycle_id_changed? + } + validate :disallow_guest_order + + attr_accessor :use_billing + + before_create :link_by_email + after_create :create_tax_charge! + + validate :has_available_shipment + validate :has_available_payment + validates :email, presence: true, + format: /\A([\w\.%\+\-']+)@([\w\-]+\.)+([\w]{2,})\z/i, + if: :require_email + + make_permalink field: :number + + before_save :update_shipping_fees!, if: :complete? + before_save :update_payment_fees!, if: :complete? + + class_attribute :update_hooks + self.update_hooks = Set.new + + # -- Scopes + scope :managed_by, lambda { |user| + if user.has_spree_role?('admin') + where(nil) + else + # Find orders that are distributed by the user or have products supplied by the user + # WARNING: This only filters orders, + # you'll need to filter line items separately using LineItem.managed_by + with_line_items_variants_and_products_outer. + where('spree_orders.distributor_id IN (?) OR spree_products.supplier_id IN (?)', + user.enterprises.select(&:id), + user.enterprises.select(&:id)). + select('DISTINCT spree_orders.*') + end + } + + scope :distributed_by_user, lambda { |user| + if user.has_spree_role?('admin') + where(nil) + else + where('spree_orders.distributor_id IN (?)', user.enterprises.select(&:id)) + end + } + + scope :with_line_items_variants_and_products_outer, lambda { + joins('LEFT OUTER JOIN spree_line_items ON (spree_line_items.order_id = spree_orders.id)'). + joins('LEFT OUTER JOIN spree_variants ON (spree_variants.id = spree_line_items.variant_id)'). + joins('LEFT OUTER JOIN spree_products ON (spree_products.id = spree_variants.product_id)') + } + + scope :not_state, lambda { |state| + where("state != ?", state) + } + + def self.by_number(number) + where(number: number) + end + + def self.between(start_date, end_date) + where(created_at: start_date..end_date) + end + + def self.by_customer(customer) + joins(:user).where("#{Spree.user_class.table_name}.email" => customer) + end + + def self.by_state(state) + where(state: state) + end + + def self.complete + where('completed_at IS NOT NULL') + end + + def self.incomplete + where(completed_at: nil) + end + + # 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 compatiblity with Calculator::PriceSack + def amount + line_items.inject(0.0) { |sum, li| sum + li.amount } + end + + def currency + self[:currency] || Spree::Config[:currency] + end + + def display_outstanding_balance + Spree::Money.new(outstanding_balance, currency: currency) + end + + def display_item_total + Spree::Money.new(item_total, currency: currency) + end + + def display_adjustment_total + Spree::Money.new(adjustment_total, currency: currency) + end + + def display_tax_total + Spree::Money.new(tax_total, currency: currency) + end + + def display_ship_total + Spree::Money.new(ship_total, currency: currency) + end + + def display_total + Spree::Money.new(total, currency: currency) + end + + def to_param + number.to_s.to_url.upcase + 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.count > 0 + end + + def changes_allowed? + complete? && distributor.andand.allow_order_changes? && order_cycle.andand.open? + end + + # Is this a free order in which case the payment step should be skipped + # This allows unpaid subscription orders to be completed. + # Subscriptions place orders at the beginning of an order cycle. They need to + # be completed to draw from stock levels and trigger emails. + def payment_required? + total.to_f > 0.0 && !skip_payment_for_subscription? + end + + # Indicates the number of items in the order + def item_count + line_items.inject(0) { |sum, li| sum + li.quantity } + 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 + Zone.match(tax_address) || Zone.default_tax + end + + # Indicates whether tax should be backed out of the price calcualtions in + # cases where prices include tax but the customer is not required to pay + # taxes in that case. + def exclude_tax? + return false unless Spree::Config[:prices_inc_tax] + + tax_zone != 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 + + # Array of totals grouped by Adjustment#label. Useful for displaying line item + # adjustments on an invoice. For example, you can display tax breakout for + # cases where tax is included in price. + def line_item_adjustment_totals + Hash[line_item_adjustments.eligible.group_by(&:label).map do |label, adjustments| + total = adjustments.sum(&:amount) + [label, Spree::Money.new(total, currency: currency)] + end] + end + + def updater + @updater ||= OrderManagement::Order::Updater.new(self) + end + + def update! + updater.update + end + + delegate :update_totals, to: :updater + + def clone_billing_address + if bill_address && ship_address.nil? + self.ship_address = bill_address.clone + else + ship_address.attributes = bill_address.attributes.except('id', 'updated_at', 'created_at') + end + true + end + + def allow_cancel? + return false unless completed? && (state != 'canceled') + + shipment_state.nil? || %w{ready backorder pending}.include?(shipment_state) + end + + def allow_resume? + # we shouldn't allow resume for legacy orders b/c we lack the information + # necessary to restore to a previous state + return false if state_changes.empty? || state_changes.last.previous_state.nil? + + true + end + + def awaiting_returns? + return_authorizations.any?(&:authorized?) + end + + # This is currently used when adding a variant to an order in the BackOffice. + # Spree::OrderContents#add is equivalent but slightly different from add_variant below. + def contents + @contents ||= Spree::OrderContents.new(self) + end + + # This is currently used when adding a variant to an order in the FrontOffice. + # This add_variant is equivalent but slightly different from Spree::OrderContents#add above. + # Spree::OrderContents#add is the more modern version in Spree history + # but this add_variant has been customized for OFN FrontOffice. + def add_variant(variant, quantity = 1, max_quantity = nil, currency = nil) + line_items(:reload) + current_item = find_line_item_by_variant(variant) + + # Notify bugsnag if we get line items with a quantity of zero + if quantity == 0 + Bugsnag.notify(RuntimeError.new("Zero Quantity Line Item"), + current_item: current_item.as_json, + line_items: line_items.map(&:id), + variant: variant.as_json) + end + + if current_item + current_item.quantity = quantity + current_item.max_quantity = max_quantity + + current_item.currency = currency unless currency.nil? + current_item.save + else + current_item = Spree::LineItem.new(quantity: quantity, max_quantity: max_quantity) + current_item.variant = variant + if currency + current_item.currency = currency unless currency.nil? + current_item.price = variant.price_in(currency).amount + else + current_item.price = variant.price + end + line_items << current_item + end + + reload + current_item + end + + def set_variant_attributes(variant, attributes) + line_item = find_line_item_by_variant(variant) + + return unless line_item + + if attributes.key?(:max_quantity) && attributes[:max_quantity].to_i < line_item.quantity + attributes[:max_quantity] = line_item.quantity + end + + line_item.assign_attributes(attributes) + line_item.save! + end + + # Associates the specified user with the order. + def associate_user!(user) + self.user = user + self.email = user.email + self.created_by = user if created_by.blank? + + return unless persisted? + + # Persist the changes we just made, + # but don't use save since we might have an invalid address associated + self.class.unscoped.where(id: id).update_all(email: user.email, + user_id: user.id, + created_by_id: created_by_id) + end + + # FIXME refactor this method and implement validation using validates_* utilities + def generate_order_number + record = true + while record + random = "R#{Array.new(9){ rand(9) }.join}" + record = self.class.find_by(number: random) + end + self.number = random if number.blank? + number + end + + def shipped_shipments + shipments.shipped + end + + def contains?(variant) + find_line_item_by_variant(variant).present? + end + + def find_line_item_by_variant(variant) + line_items.detect { |line_item| line_item.variant_id == variant.id } + end + + def ship_total + adjustments.shipping.map(&:amount).sum + end + + def tax_total + adjustments.tax.map(&:amount).sum + 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) + end + + def outstanding_balance + total - payment_total + end + + def outstanding_balance? + outstanding_balance != 0 + end + + def name + address = bill_address || ship_address + return unless address + + "#{address.firstname} #{address.lastname}" + end + + def can_ship? + complete? || resumed? || awaiting_return? || returned? + end + + def credit_cards + credit_card_ids = payments.from_credit_card.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! + touch :completed_at + + adjustments.update_all state: 'closed' + + # 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 + updater.before_save_hook + save + updater.run_hooks + + deliver_order_confirmation_email + + state_changes.create( + previous_state: 'cart', + next_state: 'complete', + name: 'order', + user_id: user_id + ) + end + + def deliver_order_confirmation_email + return if subscription.present? + + Delayed::Job.enqueue ConfirmOrderJob.new(id) + end + + # Helper methods for checkout steps + def paid? + payment_state == 'paid' || payment_state == 'credit_owed' + end + + def available_payment_methods + @available_payment_methods ||= PaymentMethod.available(:front_end) + end + + # "Checkout" is the initial state and, for card payments, "pending" is the state after auth + # These are both valid states to process the payment + def pending_payments + (payments.select(&:pending?) + + payments.select(&:processing?) + + payments.select(&:checkout?)).uniq + end + + # processes any pending payments and must return a boolean as it's + # return value is used by the checkout state_machine to determine + # success or failure of the 'complete' event for the order + # + # Returns: + # - true if all pending_payments processed successfully + # - true if a payment failed, ie. raised a GatewayError + # which gets rescued and converted to TRUE when + # :allow_checkout_gateway_error is set to true + # - false if a payment failed, ie. raised a GatewayError + # which gets rescued and converted to FALSE when + # :allow_checkout_on_gateway_error is set to false + # + def process_payments! + raise Core::GatewayError, Spree.t(:no_pending_payments) if pending_payments.empty? + + pending_payments.each do |payment| + break if payment_total >= total + + payment.process! + + if payment.completed? + self.payment_total += payment.amount + end + end + rescue Core::GatewayError => e + result = !!Spree::Config[:allow_checkout_on_gateway_error] + errors.add(:base, e.message) && (return result) + end + + def billing_firstname + bill_address.try(:firstname) + end + + def billing_lastname + bill_address.try(:lastname) + end + + def products + line_items.map(&:product) + end + + def variants + line_items.map(&:variant) + end + + def insufficient_stock_lines + line_items.select(&:insufficient_stock?) + end + + def empty! + line_items.destroy_all + adjustments.destroy_all + payments.clear + shipments.destroy_all + end + + def clear_adjustments! + adjustments.destroy_all + line_item_adjustments.destroy_all + end + + def has_step?(step) + checkout_steps.include?(step) + end + + def state_changed(name) + state = "#{name}_state" + return unless persisted? + + old_state = __send__("#{state}_was") + state_changes.create( + previous_state: old_state, + next_state: __send__(state), + name: name, + user_id: user_id + ) + end + + def shipped? + %w(partial shipped).include?(shipment_state) + end + + # Does this order have shipments that can be shipped? + def ready_to_ship? + shipments.any?(&:can_ship?) + end + + # Ship all pending orders + def ship + shipments.each do |s| + s.ship if s.can_ship? + end + end + + def line_item_variants + if line_items.loaded? + line_items.map(&:variant) + else + line_items.includes(:variant).map(&:variant) + end + end + + # Show already bought line items of this order cycle + def finalised_line_items + return [] unless order_cycle && user && distributor + + order_cycle.items_bought_by_user(user, distributor) + end + + def create_proposed_shipments + adjustments.shipping.delete_all + shipments.destroy_all + + packages = OrderManagement::Stock::Coordinator.new(self).packages + packages.each do |package| + shipments << package.to_shipment + end + + shipments + 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 + return unless shipments.any? + + shipments.destroy_all + update_column(:state, "address") + end + + def refresh_shipment_rates + shipments.map(&:refresh_rates) + end + + # Check that line_items in the current order are available from a newly selected distribution + def products_available_from_new_distribution + return if OrderCycleDistributedVariants.new(order_cycle, distributor) + .distributes_order_variants?(self) + + errors.add(:base, I18n.t(:spree_order_availability_error)) + end + + def disallow_guest_order + return unless using_guest_checkout? && registered_email? + + errors.add(:base, I18n.t('devise.failure.already_registered')) + end + + # After changing line items of a completed order + def update_shipping_fees! + shipments.each do |shipment| + next if shipment.shipped? + + update_adjustment! shipment.adjustment if shipment.adjustment + save_or_rescue_shipment(shipment) + end + end + + def save_or_rescue_shipment(shipment) + shipment.save # updates included tax + rescue ActiveRecord::RecordNotUnique => e + # This error was seen in production on `shipment.save` above. + # It caused lost payments and duplicate payments due to database rollbacks. + # While we don't understand the cause of this error yet, we rescue here + # because an outdated shipping fee is not as bad as a lost payment. + # And the shipping fee is already up-to-date when this error occurs. + # https://github.com/openfoodfoundation/openfoodnetwork/issues/3924 + Bugsnag.notify(e) do |report| + report.add_tab(:order, attributes) + report.add_tab(:shipment, shipment.attributes) + report.add_tab(:shipment_in_db, Spree::Shipment.find_by(id: shipment.id).attributes) + end + end + + # After changing line items of a completed order + def update_payment_fees! + payments.each do |payment| + next if payment.completed? + + update_adjustment! payment.adjustment if payment.adjustment + payment.save + end + end + + def update_distribution_charge! + # `with_lock` acquires an exclusive row lock on order so no other + # requests can update it until the transaction is commited. + # See https://github.com/rails/rails/blob/3-2-stable/activerecord/lib/active_record/locking/pessimistic.rb#L69 + # and https://www.postgresql.org/docs/current/static/sql-select.html#SQL-FOR-UPDATE-SHARE + with_lock do + EnterpriseFee.clear_all_adjustments_on_order self + + loaded_line_items = + line_items.includes(variant: :product, order: [:distributor, :order_cycle]).all + + loaded_line_items.each do |line_item| + if provided_by_order_cycle? line_item + OpenFoodNetwork::EnterpriseFeeCalculator.new.create_line_item_adjustments_for line_item + end + end + + if order_cycle + OpenFoodNetwork::EnterpriseFeeCalculator.new.create_order_adjustments_for self + end + end + end + + def set_order_cycle!(order_cycle) + return if self.order_cycle == order_cycle + + self.order_cycle = order_cycle + self.distributor = nil unless order_cycle.nil? || order_cycle.has_distributor?(distributor) + empty! + save! + end + + def remove_variant(variant) + line_items(:reload) + current_item = find_line_item_by_variant(variant) + current_item.andand.destroy + end + + def cap_quantity_at_stock! + line_items.includes(variant: :stock_items).find_each(&:cap_quantity_at_stock!) + end + + def set_distributor!(distributor) + self.distributor = distributor + self.order_cycle = nil unless order_cycle.andand.has_distributor? distributor + save! + end + + def set_distribution!(distributor, order_cycle) + self.distributor = distributor + self.order_cycle = order_cycle + save! + end + + def distribution_set? + distributor && order_cycle + end + + def shipping_tax + adjustments(:reload).shipping.sum(&:included_tax) + end + + def enterprise_fee_tax + adjustments(:reload).enterprise_fee.sum(&:included_tax) + end + + def total_tax + (adjustments + price_adjustments).sum(&:included_tax) + end + + def price_adjustments + adjustments = [] + + line_items.each { |line_item| adjustments.concat line_item.adjustments } + + adjustments + end + + def price_adjustment_totals + Hash[tax_adjustment_totals.map do |tax_rate, tax_amount| + [tax_rate.name, + Spree::Money.new(tax_amount, currency: currency)] + end] + end + + def has_taxes_included + !line_items.with_tax.empty? + end + + def address_from_distributor + address = distributor.address.clone + if bill_address + address.firstname = bill_address.firstname + address.lastname = bill_address.lastname + address.phone = bill_address.phone + end + address + end + + # Update attributes of a record in the database without callbacks, validations etc. + # This was originally an extension to ActiveRecord in Spree but only used for Spree::Order + def update_attributes_without_callbacks(attributes) + assign_attributes(attributes) + Spree::Order.where(id: id).update_all(attributes) + 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 + return true unless new_record? || (state == 'cart') + end + + def ensure_line_items_present + return if line_items.present? + + errors.add(:base, Spree.t(:there_are_no_items_for_this_order)) && (return false) + end + + def has_available_shipment + return unless has_step?("delivery") + return unless address? + return unless ship_address&.valid? + # errors.add(:base, :no_shipping_methods_available) if available_shipping_methods.empty? + end + + def ensure_available_shipping_rates + return unless shipments.empty? || shipments.any? { |shipment| shipment.shipping_rates.blank? } + + errors.add(:base, Spree.t(:items_cannot_be_shipped)) && (return false) + end + + def has_available_payment + return unless delivery? + # errors.add(:base, :no_payment_methods_available) if available_payment_methods.empty? + end + + def after_cancel + shipments.each(&:cancel!) + + OrderMailer.cancel_email(id).deliver + self.payment_state = 'credit_owed' unless shipped? + end + + def after_resume + shipments.each(&:resume!) + end + + def use_billing? + @use_billing == true || @use_billing == 'true' || @use_billing == '1' + end + + def set_currency + self.currency = Spree::Config[:currency] if self[:currency].nil? + end + + def using_guest_checkout? + require_email && !user.andand.id + end + + def registered_email? + Spree.user_class.exists?(email: email) + end + + def adjustments_fetcher + @adjustments_fetcher ||= OrderAdjustmentsFetcher.new(self) + end + + def skip_payment_for_subscription? + subscription.present? && order_cycle.orders_close_at.andand > Time.zone.now + end + + def provided_by_order_cycle?(line_item) + order_cycle_variants = order_cycle.andand.variants || [] + order_cycle_variants.include? line_item.variant + end + + def require_customer? + return true unless new_record? || state == 'cart' + end + + def customer_is_valid? + return true unless require_customer? + + customer.present? && customer.enterprise_id == distributor_id && customer.email == email_for_customer + end + + def email_for_customer + (user.andand.email || email).andand.downcase + end + + def associate_customer + return customer if customer.present? + + self.customer = Customer.of(distributor).find_by(email: email_for_customer) + end + + def ensure_customer + return if associate_customer + + customer_name = bill_address.andand.full_name + self.customer = Customer.create(enterprise: distributor, email: email_for_customer, + user: user, name: customer_name, + bill_address: bill_address.andand.clone, + ship_address: ship_address.andand.clone) + end + + def update_adjustment!(adjustment) + return if adjustment.finalized? + + state = adjustment.state + adjustment.state = 'open' + adjustment.update! + update! + adjustment.state = state + end + + # object_params sets the payment amount to the order total, but it does this + # before the shipping method is set. This results in the customer not being + # charged for their order's shipping. To fix this, we refresh the payment + # amount here. + def charge_shipping_and_payment_fees! + update_totals + return unless pending_payments.any? + + pending_payments.first.update_attribute :amount, total + end + end +end diff --git a/app/models/spree/order_contents.rb b/app/models/spree/order_contents.rb new file mode 100644 index 0000000000..74650adc1f --- /dev/null +++ b/app/models/spree/order_contents.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Spree + class OrderContents + attr_accessor :order, :currency + + def initialize(order) + @order = order + end + + # Get current line item for variant if exists + # Add variant qty to line_item + def add(variant, quantity = 1, currency = nil, shipment = nil) + line_item = order.find_line_item_by_variant(variant) + add_to_line_item(line_item, variant, quantity, currency, shipment) + end + + # Get current line item for variant + # Remove variant qty from line_item + def remove(variant, quantity = 1, shipment = nil) + line_item = order.find_line_item_by_variant(variant) + + unless line_item + raise ActiveRecord::RecordNotFound, "Line item not found for variant #{variant.sku}" + end + + remove_from_line_item(line_item, variant, quantity, shipment) + end + + private + + def add_to_line_item(line_item, variant, quantity, currency = nil, shipment = nil) + if line_item + line_item.target_shipment = shipment + line_item.quantity += quantity.to_i + line_item.currency = currency unless currency.nil? + else + line_item = order.line_items.new(quantity: quantity, variant: variant) + line_item.target_shipment = shipment + if currency + line_item.currency = currency unless currency.nil? + line_item.price = variant.price_in(currency).amount + else + line_item.price = variant.price + end + end + + line_item.save + order.reload + line_item + end + + def remove_from_line_item(line_item, _variant, quantity, shipment = nil) + line_item.quantity += -quantity + line_item.target_shipment = shipment + + if line_item.quantity == 0 + Spree::OrderInventory.new(order).verify(line_item, shipment) + line_item.destroy + else + line_item.save! + end + + order.reload + line_item + end + end +end diff --git a/app/models/spree/order_decorator.rb b/app/models/spree/order_decorator.rb deleted file mode 100644 index 2b033065cc..0000000000 --- a/app/models/spree/order_decorator.rb +++ /dev/null @@ -1,446 +0,0 @@ -require 'open_food_network/enterprise_fee_calculator' -require 'open_food_network/feature_toggle' -require 'open_food_network/tag_rule_applicator' -require 'concerns/order_shipment' - -ActiveSupport::Notifications.subscribe('spree.order.contents_changed') do |_name, _start, _finish, _id, payload| - payload[:order].reload.update_distribution_charge! -end - -Spree::Order.class_eval do - prepend OrderShipment - - delegate :admin_and_handling_total, :payment_fee, :ship_total, to: :adjustments_fetcher - - belongs_to :order_cycle - belongs_to :distributor, class_name: 'Enterprise' - belongs_to :customer - has_one :proxy_order - has_one :subscription, through: :proxy_order - - # This removes "inverse_of: source" which breaks shipment adjustment calculations - # This change is done in Spree 2.1 (see https://github.com/spree/spree/commit/3fa44165c7825f79a2fa4eb79b99dc29944c5d55) - # When OFN gets to Spree 2.1, this can be removed - has_many :adjustments, -> { order "#{Spree::Adjustment.table_name}.created_at ASC" }, - as: :adjustable, - dependent: :destroy - - validates :customer, presence: true, if: :require_customer? - validate :products_available_from_new_distribution, if: lambda { distributor_id_changed? || order_cycle_id_changed? } - validate :disallow_guest_order - - # The EmailValidator introduced in Spree 2.1 is not working - # So here we remove it and re-introduce the regexp validation rule from Spree 2.0 - _validate_callbacks.each do |callback| - if callback.raw_filter.respond_to? :attributes - callback.raw_filter.attributes.delete :email - end - end - validates :email, presence: true, format: /\A([\w\.%\+\-']+)@([\w\-]+\.)+([\w]{2,})\z/i, - if: :require_email - - before_validation :associate_customer, unless: :customer_id? - before_validation :ensure_customer, unless: :customer_is_valid? - - before_save :update_shipping_fees!, if: :complete? - before_save :update_payment_fees!, if: :complete? - - # Orders are confirmed with their payment, we don't use the confirm step. - # Here we remove that step from Spree's checkout state machine. - # See: https://guides.spreecommerce.org/developer/checkout.html#modifying-the-checkout-flow - remove_checkout_step :confirm - - state_machine.after_transition to: :payment, do: :charge_shipping_and_payment_fees! - - state_machine.event :restart_checkout do - transition to: :cart, unless: :completed? - end - - # -- Scopes - scope :managed_by, lambda { |user| - if user.has_spree_role?('admin') - where(nil) - else - # Find orders that are distributed by the user or have products supplied by the user - # WARNING: This only filters orders, you'll need to filter line items separately using LineItem.managed_by - with_line_items_variants_and_products_outer. - where('spree_orders.distributor_id IN (?) OR spree_products.supplier_id IN (?)', - user.enterprises.select(&:id), - user.enterprises.select(&:id)). - select('DISTINCT spree_orders.*') - end - } - - scope :distributed_by_user, lambda { |user| - if user.has_spree_role?('admin') - where(nil) - else - where('spree_orders.distributor_id IN (?)', user.enterprises.select(&:id)) - end - } - - scope :with_line_items_variants_and_products_outer, lambda { - joins('LEFT OUTER JOIN spree_line_items ON (spree_line_items.order_id = spree_orders.id)'). - joins('LEFT OUTER JOIN spree_variants ON (spree_variants.id = spree_line_items.variant_id)'). - joins('LEFT OUTER JOIN spree_products ON (spree_products.id = spree_variants.product_id)') - } - - scope :not_state, lambda { |state| - where("state != ?", state) - } - - def updater - @updater ||= OrderManagement::Order::Updater.new(self) - end - - def create_proposed_shipments - adjustments.shipping.delete_all - shipments.destroy_all - - packages = OrderManagement::Stock::Coordinator.new(self).packages - packages.each do |package| - shipments << package.to_shipment - end - - shipments - end - - # -- Methods - def products_available_from_new_distribution - # Check that the line_items in the current order are available from a newly selected distribution - errors.add(:base, I18n.t(:spree_order_availability_error)) unless OrderCycleDistributedVariants.new(order_cycle, distributor).distributes_order_variants?(self) - end - - def using_guest_checkout? - require_email && !user.andand.id - end - - def registered_email? - Spree.user_class.exists?(email: email) - end - - def disallow_guest_order - if using_guest_checkout? && registered_email? - errors.add(:base, I18n.t('devise.failure.already_registered')) - end - end - - def empty_with_clear_shipping_and_payments! - empty_without_clear_shipping_and_payments! - payments.clear - shipments.destroy_all - end - alias_method_chain :empty!, :clear_shipping_and_payments - - def set_order_cycle!(order_cycle) - return if self.order_cycle == order_cycle - - self.order_cycle = order_cycle - self.distributor = nil unless order_cycle.nil? || order_cycle.has_distributor?(distributor) - empty! - save! - end - - # "Checkout" is the initial state and, for card payments, "pending" is the state after authorization - # These are both valid states to process the payment - def pending_payments - (payments.select(&:pending?) + payments.select(&:processing?) + payments.select(&:checkout?)).uniq - end - - def remove_variant(variant) - line_items(:reload) - current_item = find_line_item_by_variant(variant) - current_item.andand.destroy - end - - # Overridden to support max_quantity - def add_variant(variant, quantity = 1, max_quantity = nil, currency = nil) - line_items(:reload) - current_item = find_line_item_by_variant(variant) - - # Notify bugsnag if we get line items with a quantity of zero - if quantity == 0 - Bugsnag.notify(RuntimeError.new("Zero Quantity Line Item"), - current_item: current_item.as_json, - line_items: line_items.map(&:id), - variant: variant.as_json) - end - - if current_item - current_item.quantity = quantity - current_item.max_quantity = max_quantity - - # This is the original behaviour, behaviour above is so that we can resolve the order populator bug - # current_item.quantity ||= 0 - # current_item.max_quantity ||= 0 - # current_item.quantity += quantity.to_i - # current_item.max_quantity += max_quantity.to_i - current_item.currency = currency unless currency.nil? - current_item.save - else - current_item = Spree::LineItem.new(quantity: quantity, max_quantity: max_quantity) - current_item.variant = variant - if currency - current_item.currency = currency unless currency.nil? - current_item.price = variant.price_in(currency).amount - else - current_item.price = variant.price - end - line_items << current_item - end - - reload - current_item - end - - # After changing line items of a completed order - def update_shipping_fees! - shipments.each do |shipment| - next if shipment.shipped? - - update_adjustment! shipment.adjustment if shipment.adjustment - save_or_rescue_shipment(shipment) - end - end - - def save_or_rescue_shipment(shipment) - shipment.save # updates included tax - rescue ActiveRecord::RecordNotUnique => e - # This error was seen in production on `shipment.save` above. - # It caused lost payments and duplicate payments due to database rollbacks. - # While we don't understand the cause of this error yet, we rescue here - # because an outdated shipping fee is not as bad as a lost payment. - # And the shipping fee is already up-to-date when this error occurs. - # https://github.com/openfoodfoundation/openfoodnetwork/issues/3924 - Bugsnag.notify(e) do |report| - report.add_tab(:order, attributes) - report.add_tab(:shipment, shipment.attributes) - report.add_tab(:shipment_in_db, Spree::Shipment.find_by(id: shipment.id).attributes) - end - end - - # After changing line items of a completed order - def update_payment_fees! - payments.each do |payment| - next if payment.completed? - - update_adjustment! payment.adjustment if payment.adjustment - payment.save - end - end - - def cap_quantity_at_stock! - line_items.includes(variant: :stock_items).all.each(&:cap_quantity_at_stock!) - end - - def set_distributor!(distributor) - self.distributor = distributor - self.order_cycle = nil unless order_cycle.andand.has_distributor? distributor - save! - end - - def set_distribution!(distributor, order_cycle) - self.distributor = distributor - self.order_cycle = order_cycle - save! - end - - def distribution_set? - distributor && order_cycle - end - - def update_distribution_charge! - # `with_lock` acquires an exclusive row lock on order so no other - # requests can update it until the transaction is commited. - # See https://github.com/rails/rails/blob/3-2-stable/activerecord/lib/active_record/locking/pessimistic.rb#L69 - # and https://www.postgresql.org/docs/current/static/sql-select.html#SQL-FOR-UPDATE-SHARE - with_lock do - EnterpriseFee.clear_all_adjustments_on_order self - - loaded_line_items = - line_items.includes(variant: :product, order: [:distributor, :order_cycle]).all - - loaded_line_items.each do |line_item| - if provided_by_order_cycle? line_item - OpenFoodNetwork::EnterpriseFeeCalculator.new.create_line_item_adjustments_for line_item - end - end - - if order_cycle - OpenFoodNetwork::EnterpriseFeeCalculator.new.create_order_adjustments_for self - end - end - end - - def set_variant_attributes(variant, attributes) - line_item = find_line_item_by_variant(variant) - - if line_item - if attributes.key?(:max_quantity) && attributes[:max_quantity].to_i < line_item.quantity - attributes[:max_quantity] = line_item.quantity - end - - line_item.assign_attributes(attributes) - line_item.save! - end - end - - def line_item_variants - if line_items.loaded? - line_items.map(&:variant) - else - line_items.includes(:variant).map(&:variant) - end - end - - # Show already bought line items of this order cycle - def finalised_line_items - return [] unless order_cycle && user && distributor - - order_cycle.items_bought_by_user(user, distributor) - end - - # Does this order have shipments that can be shipped? - def ready_to_ship? - shipments.any?(&:can_ship?) - end - - # Ship all pending orders - def ship - shipments.each do |s| - s.ship if s.can_ship? - end - end - - def shipping_tax - adjustments(:reload).shipping.sum(&:included_tax) - end - - def enterprise_fee_tax - adjustments(:reload).enterprise_fee.sum(&:included_tax) - end - - def total_tax - (adjustments + price_adjustments).sum(&:included_tax) - end - - def price_adjustments - adjustments = [] - - line_items.each { |line_item| adjustments.concat line_item.adjustments } - - adjustments - end - - def price_adjustment_totals - Hash[tax_adjustment_totals.map do |tax_rate, tax_amount| - [tax_rate.name, - Spree::Money.new(tax_amount, currency: currency)] - end] - end - - def has_taxes_included - !line_items.with_tax.empty? - end - - # Overrride of Spree method, that allows us to send separate confirmation emails to user and shop owners - def deliver_order_confirmation_email - if subscription.blank? - Delayed::Job.enqueue ConfirmOrderJob.new(id) - end - end - - def changes_allowed? - complete? && distributor.andand.allow_order_changes? && order_cycle.andand.open? - end - - # Override Spree method to allow unpaid orders to be completed. - # Subscriptions place orders at the beginning of an order cycle. They need to - # be completed to draw from stock levels and trigger emails. - # Spree doesn't allow this. Other options would be to introduce an additional - # order state or implement a special proxy payment method. - # https://github.com/openfoodfoundation/openfoodnetwork/pull/3012#issuecomment-438146484 - def payment_required? - total.to_f > 0.0 && !skip_payment_for_subscription? - end - - def address_from_distributor - address = distributor.address.clone - if bill_address - address.firstname = bill_address.firstname - address.lastname = bill_address.lastname - address.phone = bill_address.phone - end - address - end - - # Update attributes of a record in the database without callbacks, validations etc. - # This was originally an extension to ActiveRecord in Spree but only used for Spree::Order - def update_attributes_without_callbacks(attributes) - assign_attributes(attributes) - Spree::Order.where(id: id).update_all(attributes) - end - - private - - def adjustments_fetcher - @adjustments_fetcher ||= OrderAdjustmentsFetcher.new(self) - end - - def skip_payment_for_subscription? - subscription.present? && order_cycle.orders_close_at.andand > Time.zone.now - end - - def provided_by_order_cycle?(line_item) - order_cycle_variants = order_cycle.andand.variants || [] - order_cycle_variants.include? line_item.variant - end - - def require_customer? - return true unless new_record? || state == 'cart' - end - - def customer_is_valid? - return true unless require_customer? - - customer.present? && customer.enterprise_id == distributor_id && customer.email == email_for_customer - end - - def email_for_customer - (user.andand.email || email).andand.downcase - end - - def associate_customer - return customer if customer.present? - - self.customer = Customer.of(distributor).find_by(email: email_for_customer) - end - - def ensure_customer - unless associate_customer - customer_name = bill_address.andand.full_name - self.customer = Customer.create(enterprise: distributor, email: email_for_customer, user: user, name: customer_name, bill_address: bill_address.andand.clone, ship_address: ship_address.andand.clone) - end - end - - def update_adjustment!(adjustment) - return if adjustment.finalized? - - state = adjustment.state - adjustment.state = 'open' - adjustment.update! - update! - adjustment.state = state - end - - # object_params sets the payment amount to the order total, but it does this - # before the shipping method is set. This results in the customer not being - # charged for their order's shipping. To fix this, we refresh the payment - # amount here. - def charge_shipping_and_payment_fees! - update_totals - return unless pending_payments.any? - - pending_payments.first.update_attribute :amount, total - end -end diff --git a/app/models/spree/order_inventory.rb b/app/models/spree/order_inventory.rb new file mode 100644 index 0000000000..8416bb4755 --- /dev/null +++ b/app/models/spree/order_inventory.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Spree + class OrderInventory + attr_accessor :order + + def initialize(order) + @order = order + end + + # Only verify inventory for completed orders (as orders in frontend checkout + # have inventory assigned via +order.create_proposed_shipment+) or when + # shipment is explicitly passed + # + # In case shipment is passed the stock location should only unstock or + # restock items if the order is completed. That is so because stock items + # are always unstocked when the order is completed through +shipment.finalize+ + def verify(line_item, shipment = nil) + if order.completed? || shipment.present? + + variant_units = inventory_units_for(line_item.variant) + + if variant_units.size < line_item.quantity + quantity = line_item.quantity - variant_units.size + + shipment ||= determine_target_shipment(line_item.variant) + add_to_shipment(shipment, line_item.variant, quantity) + elsif variant_units.size > line_item.quantity + remove(line_item, variant_units, shipment) + end + else + true + end + end + + def inventory_units_for(variant) + units = order.shipments.collect{ |s| s.inventory_units.to_a }.flatten + units.group_by(&:variant_id)[variant.id] || [] + end + + private + + def remove(line_item, variant_units, shipment = nil) + quantity = variant_units.size - line_item.quantity + + if shipment.present? + remove_from_shipment(shipment, line_item.variant, quantity) + else + order.shipments.each do |each_shipment| + break if quantity == 0 + + quantity -= remove_from_shipment(each_shipment, line_item.variant, quantity) + end + end + end + + # Returns either one of the shipment: + # + # first unshipped that already includes this variant + # first unshipped that's leaving from a stock_location that stocks this variant + def determine_target_shipment(variant) + target_shipment = order.shipments.detect do |shipment| + (shipment.ready? || shipment.pending?) && shipment.include?(variant) + end + + target_shipment || order.shipments.detect do |shipment| + (shipment.ready? || shipment.pending?) && + variant.stock_location_ids.include?(shipment.stock_location_id) + end + end + + def add_to_shipment(shipment, variant, quantity) + on_hand, back_order = shipment.stock_location.fill_status(variant, quantity) + + on_hand.times { shipment.set_up_inventory('on_hand', variant, order) } + back_order.times { shipment.set_up_inventory('backordered', variant, order) } + + # adding to this shipment, and removing from stock_location + if order.completed? + shipment.stock_location.unstock(variant, quantity, shipment) + end + + quantity + end + + def remove_from_shipment(shipment, variant, quantity) + return 0 if quantity == 0 || shipment.shipped? + + shipment_units = shipment.inventory_units_for(variant).reject do |variant_unit| + variant_unit.state == 'shipped' + end.sort_by(&:state) + + removed_quantity = 0 + + shipment_units.each do |inventory_unit| + break if removed_quantity == quantity + + inventory_unit.destroy + removed_quantity += 1 + end + + shipment.destroy if shipment.inventory_units.count == 0 + + # removing this from shipment, and adding to stock_location + if order.completed? + shipment.stock_location.restock variant, removed_quantity, shipment + end + + removed_quantity + end + end +end diff --git a/app/models/spree/return_authorization.rb b/app/models/spree/return_authorization.rb new file mode 100644 index 0000000000..41c17cb10f --- /dev/null +++ b/app/models/spree/return_authorization.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Spree + class ReturnAuthorization < ActiveRecord::Base + belongs_to :order, class_name: 'Spree::Order' + + has_many :inventory_units + has_one :stock_location + before_create :generate_number + before_save :force_positive_amount + + validates :order, presence: true + validates :amount, numericality: true + validate :must_have_shipped_units + + state_machine initial: :authorized do + after_transition to: :received, do: :process_return + + event :receive do + transition to: :received, from: :authorized, if: :allow_receive? + end + event :cancel do + transition to: :canceled, from: :authorized + end + end + + def currency + order.nil? ? Spree::Config[:currency] : order.currency + end + + def display_amount + Spree::Money.new(amount, currency: currency) + end + + def add_variant(variant_id, quantity) + order_units = returnable_inventory.group_by(&:variant_id) + returned_units = inventory_units.group_by(&:variant_id) + return false if order_units.empty? + + count = 0 + + if returned_units[variant_id].nil? || returned_units[variant_id].size < quantity + count = returned_units[variant_id].nil? ? 0 : returned_units[variant_id].size + + order_units[variant_id].each do |inventory_unit| + next unless inventory_unit.return_authorization.nil? && count < quantity + + inventory_unit.return_authorization = self + inventory_unit.save! + + count += 1 + end + elsif returned_units[variant_id].size > quantity + (returned_units[variant_id].size - quantity).times do |i| + returned_units[variant_id][i].return_authorization_id = nil + returned_units[variant_id][i].save! + end + end + + order.authorize_return! if !inventory_units.reload.empty? && !order.awaiting_return? + end + + def returnable_inventory + order.shipped_shipments.collect{ |s| s.inventory_units.to_a }.flatten + end + + private + + def must_have_shipped_units + return unless order.nil? || order.shipped_shipments.none? + + errors.add(:order, Spree.t(:has_no_shipped_units)) + end + + def generate_number + return if number + + record = true + while record + random = "RMA#{Array.new(9){ rand(9) }.join}" + record = self.class.find_by(number: random) + end + self.number = random + end + + def process_return + inventory_units.each do |iu| + iu.return! + Spree::StockMovement.create!(stock_item_id: iu.find_stock_item.id, quantity: 1) + end + + credit = Adjustment.new(amount: amount.abs * -1, label: Spree.t(:rma_credit)) + credit.source = self + credit.adjustable = order + credit.save + + order.return if inventory_units.all?(&:returned?) + end + + def allow_receive? + !inventory_units.empty? + end + + def force_positive_amount + self.amount = amount.abs + end + end +end diff --git a/app/models/spree/state_change.rb b/app/models/spree/state_change.rb new file mode 100644 index 0000000000..9f52777095 --- /dev/null +++ b/app/models/spree/state_change.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Spree + class StateChange < ActiveRecord::Base + belongs_to :user + belongs_to :stateful, polymorphic: true + before_create :assign_user + + def <=>(other) + created_at <=> other.created_at + end + + def assign_user + true # don't stop the filters + end + end +end diff --git a/app/models/spree/tokenized_permission.rb b/app/models/spree/tokenized_permission.rb new file mode 100644 index 0000000000..58c5882c3a --- /dev/null +++ b/app/models/spree/tokenized_permission.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Spree + class TokenizedPermission < ActiveRecord::Base + belongs_to :permissable, polymorphic: true + end +end diff --git a/spec/controllers/spree/admin/orders/payments/payments_controller_refunds_spec.rb b/spec/controllers/spree/admin/orders/payments/payments_controller_refunds_spec.rb index 594b9e2dbb..1b97a6fcd2 100644 --- a/spec/controllers/spree/admin/orders/payments/payments_controller_refunds_spec.rb +++ b/spec/controllers/spree/admin/orders/payments/payments_controller_refunds_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'spec_helper' +require 'spree/core/gateway_error' describe Spree::Admin::PaymentsController, type: :controller do include StripeHelper diff --git a/spec/models/spree/inventory_unit_spec.rb b/spec/models/spree/inventory_unit_spec.rb new file mode 100644 index 0000000000..e83056c264 --- /dev/null +++ b/spec/models/spree/inventory_unit_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Spree::InventoryUnit do + let(:stock_location) { create(:stock_location_with_items) } + let(:stock_item) { stock_location.stock_items.order(:id).first } + + context "#backordered_for_stock_item" do + let(:order) { create(:order) } + + let(:shipment) do + shipment = Spree::Shipment.new + shipment.stock_location = stock_location + shipment.shipping_methods << create(:shipping_method) + shipment.order = order + # We don't care about this in this test + allow(shipment).to receive(:ensure_correct_adjustment) + shipment.tap(&:save!) + end + + let!(:unit) do + unit = shipment.inventory_units.build + unit.state = 'backordered' + unit.variant_id = stock_item.variant.id + unit.tap(&:save!) + end + + # Regression for Spree #3066 + it "returns modifiable objects" do + units = Spree::InventoryUnit.backordered_for_stock_item(stock_item) + expect { units.first.save! }.to_not raise_error + end + + it "finds inventory units from its stock location when the unit's variant matches the stock item's variant" do + expect(Spree::InventoryUnit.backordered_for_stock_item(stock_item)).to eq [unit] + end + + it "does not find inventory units that don't match the stock item's variant" do + other_variant_unit = shipment.inventory_units.build + other_variant_unit.state = 'backordered' + other_variant_unit.variant = create(:variant) + other_variant_unit.save! + + expect(Spree::InventoryUnit.backordered_for_stock_item(stock_item)).to_not include(other_variant_unit) + end + end + + context "variants deleted" do + let!(:unit) do + Spree::InventoryUnit.create(variant: stock_item.variant) + end + + it "can still fetch variant" do + unit.variant.destroy + expect(unit.reload.variant).to be_a Spree::Variant + end + end + + context "#finalize_units!" do + let!(:stock_location) { create(:stock_location) } + let(:variant) { create(:variant) } + let(:inventory_units) { + [ + create(:inventory_unit, variant: variant), + create(:inventory_unit, variant: variant) + ] + } + + it "should create a stock movement" do + Spree::InventoryUnit.finalize_units!(inventory_units) + expect(inventory_units.any?(&:pending)).to be_falsy + end + end +end diff --git a/spec/models/spree/line_item_spec.rb b/spec/models/spree/line_item_spec.rb index 4410a00975..5aec211ee9 100644 --- a/spec/models/spree/line_item_spec.rb +++ b/spec/models/spree/line_item_spec.rb @@ -2,6 +2,143 @@ require 'spec_helper' module Spree describe LineItem do + let(:order) { create :order_with_line_items, line_items_count: 1 } + let(:line_item) { order.line_items.first } + + context '#save' do + it 'should update inventory, totals, and tax' do + # Regression check for Spree #1481 + expect(line_item.order).to receive(:create_tax_charge!) + expect(line_item.order).to receive(:update!) + line_item.quantity = 2 + line_item.save + end + end + + context '#destroy' do + # Regression test for Spree #1481 + it "applies tax adjustments" do + expect(line_item.order).to receive(:create_tax_charge!) + line_item.destroy + end + + it "fetches deleted products" do + line_item.product.destroy + expect(line_item.reload.product).to be_a Spree::Product + end + + it "fetches deleted variants" do + line_item.variant.destroy + expect(line_item.reload.variant).to be_a Spree::Variant + end + end + + # Test for Spree #3391 + context '#copy_price' do + it "copies over a variant's prices" do + line_item.price = nil + line_item.cost_price = nil + line_item.currency = nil + line_item.copy_price + variant = line_item.variant + expect(line_item.price).to eq variant.price + expect(line_item.cost_price).to eq variant.cost_price + expect(line_item.currency).to eq variant.currency + end + end + + # Test for Spree #3481 + context '#copy_tax_category' do + it "copies over a variant's tax category" do + line_item.tax_category = nil + line_item.copy_tax_category + expect(line_item.tax_category).to eq line_item.variant.product.tax_category + end + end + + describe '.currency' do + it 'returns the globally configured currency' do + line_item.currency == 'USD' + end + end + + describe ".money" do + before do + line_item.price = 3.50 + line_item.quantity = 2 + end + + it "returns a Spree::Money representing the total for this line item" do + expect(line_item.money.to_s).to eq "$7.00" + end + end + + describe '.single_money' do + before { line_item.price = 3.50 } + it "returns a Spree::Money representing the price for one variant" do + expect(line_item.single_money.to_s).to eq "$3.50" + end + end + + context "has inventory (completed order so items were already unstocked)" do + let(:order) { Spree::Order.create } + let(:variant) { create(:variant) } + + context "nothing left on stock" do + before do + variant.stock_items.update_all count_on_hand: 5, backorderable: false + order.contents.add(variant, 5) + order.create_proposed_shipments + order.finalize! + end + + it "allows to decrease item quantity" do + line_item = order.line_items.first + line_item.quantity -= 1 + line_item.target_shipment = order.shipments.first + + line_item.save + expect(line_item.errors[:quantity]).to be_empty + end + + it "doesnt allow to increase item quantity" do + line_item = order.line_items.first + line_item.quantity += 2 + line_item.target_shipment = order.shipments.first + + line_item.save + expect(line_item.errors[:quantity].first).to include "is out of stock" + end + end + + context "2 items left on stock" do + before do + variant.stock_items.update_all count_on_hand: 7, backorderable: false + order.contents.add(variant, 5) + order.create_proposed_shipments + order.finalize! + end + + it "allows to increase quantity up to stock availability" do + line_item = order.line_items.first + line_item.quantity += 2 + line_item.target_shipment = order.shipments.first + + line_item.save + expect(line_item.errors[:quantity]).to be_empty + end + + it "doesnt allow to increase quantity over stock availability" do + line_item = order.line_items.first + line_item.quantity += 3 + line_item.target_shipment = order.shipments.first + + line_item.save + expect(line_item.errors[:quantity].first).to include "is out of stock" + end + end + end + describe "scopes" do let(:o) { create(:order) } diff --git a/spec/models/spree/order/address_spec.rb b/spec/models/spree/order/address_spec.rb new file mode 100644 index 0000000000..78ad56a0ef --- /dev/null +++ b/spec/models/spree/order/address_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Spree::Order do + let(:order) { Spree::Order.new } + + context 'validation' do + context "when @use_billing is populated" do + before do + order.bill_address = build(:address) + order.ship_address = nil + end + + context "with true" do + before { order.use_billing = true } + + it "clones the bill address to the ship address" do + order.valid? + expect(order.ship_address).to eq order.bill_address + end + end + + context "with 'true'" do + before { order.use_billing = 'true' } + + it "clones the bill address to the shipping" do + order.valid? + expect(order.ship_address).to eq order.bill_address + end + end + + context "with '1'" do + before { order.use_billing = '1' } + + it "clones the bill address to the shipping" do + order.valid? + expect(order.ship_address).to eq order.bill_address + end + end + + context "with something other than a 'truthful' value" do + before { order.use_billing = '0' } + + it "does not clone the bill address to the shipping" do + order.valid? + expect(order.ship_address).to be_nil + end + end + end + end +end diff --git a/spec/models/spree/order/adjustments_spec.rb b/spec/models/spree/order/adjustments_spec.rb new file mode 100644 index 0000000000..b3ad28b40d --- /dev/null +++ b/spec/models/spree/order/adjustments_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'spec_helper' +describe Spree::Order do + let(:order) { Spree::Order.new } + + context "clear_adjustments" do + let(:adjustment) { double("Adjustment") } + + it "destroys all order adjustments" do + allow(order).to receive_messages(adjustments: adjustment) + expect(adjustment).to receive(:destroy_all) + order.clear_adjustments! + end + + it "destroy all line item adjustments" do + allow(order).to receive_messages(line_item_adjustments: adjustment) + expect(adjustment).to receive(:destroy_all) + order.clear_adjustments! + end + end + + context "totaling adjustments" do + let(:adjustment1) { build(:adjustment, amount: 5) } + let(:adjustment2) { build(:adjustment, amount: 10) } + + context "#ship_total" do + it "should return the correct amount" do + allow(order).to receive_message_chain :adjustments, shipping: [adjustment1, adjustment2] + expect(order.ship_total).to eq 15 + end + end + + context "#tax_total" do + it "should return the correct amount" do + allow(order).to receive_message_chain :adjustments, tax: [adjustment1, adjustment2] + expect(order.tax_total).to eq 15 + end + end + end + + context "line item adjustment totals" do + before { @order = Spree::Order.create! } + + context "when there are no line item adjustments" do + before { allow(@order).to receive_message_chain(:line_item_adjustments, eligible: []) } + + it "should return an empty hash" do + expect(@order.line_item_adjustment_totals).to eq({}) + end + end + + context "when there are two adjustments with different labels" do + let(:adj1) { build(:adjustment, amount: 10, label: "Foo") } + let(:adj2) { build(:adjustment, amount: 20, label: "Bar") } + + before do + allow(@order).to receive_message_chain(:line_item_adjustments, eligible: [adj1, adj2]) + end + + it "should return exactly two totals" do + expect(@order.line_item_adjustment_totals.size).to eq 2 + end + + it "should return the correct totals" do + expect(@order.line_item_adjustment_totals["Foo"]).to eq Spree::Money.new(10) + expect(@order.line_item_adjustment_totals["Bar"]).to eq Spree::Money.new(20) + end + end + + context "when there are two adjustments with one label and a single adjustment with another" do + let(:adj1) { build(:adjustment, amount: 10, label: "Foo") } + let(:adj2) { build(:adjustment, amount: 20, label: "Bar") } + let(:adj3) { build(:adjustment, amount: 40, label: "Bar") } + + before do + allow(@order).to receive_message_chain(:line_item_adjustments, eligible: [adj1, adj2, adj3]) + end + + it "should return exactly two totals" do + expect(@order.line_item_adjustment_totals.size).to eq 2 + end + it "should return the correct totals" do + expect(@order.line_item_adjustment_totals["Foo"]).to eq Spree::Money.new(10) + expect(@order.line_item_adjustment_totals["Bar"]).to eq Spree::Money.new(60) + end + end + end + + context "line item adjustments" do + before do + @order = Spree::Order.create! + allow(@order).to receive_messages line_items: [line_item1, line_item2] + end + + let(:line_item1) { create(:line_item, order: @order) } + let(:line_item2) { create(:line_item, order: @order) } + + context "when there are no line item adjustments" do + it "should return nothing if line items have no adjustments" do + expect(@order.line_item_adjustments).to be_empty + end + end + + context "when only one line item has adjustments" do + before do + @adj1 = line_item1.adjustments.create( + amount: 2, + source: line_item1, + label: "VAT 5%" + ) + + @adj2 = line_item1.adjustments.create( + amount: 5, + source: line_item1, + label: "VAT 10%" + ) + end + + it "should return the adjustments for that line item" do + expect(@order.line_item_adjustments).to include @adj1 + expect(@order.line_item_adjustments).to include @adj2 + end + end + + context "when more than one line item has adjustments" do + before do + @adj1 = line_item1.adjustments.create( + amount: 2, + source: line_item1, + label: "VAT 5%" + ) + + @adj2 = line_item2.adjustments.create( + amount: 5, + source: line_item2, + label: "VAT 10%" + ) + end + + it "should return the adjustments for each line item" do + expect(@order.line_item_adjustments).to include @adj1 + expect(@order.line_item_adjustments).to include @adj2 + end + end + end +end diff --git a/spec/models/spree/order/callbacks_spec.rb b/spec/models/spree/order/callbacks_spec.rb new file mode 100644 index 0000000000..a3cb0744ec --- /dev/null +++ b/spec/models/spree/order/callbacks_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Spree::Order do + let(:order) { build(:order) } + before do + Spree::Order.define_state_machine! + end + + context "validations" do + context "email validation" do + # Regression test for Spree #1238 + it "o'brien@gmail.com is a valid email address" do + order.state = 'address' + order.email = "o'brien@gmail.com" + expect(order.errors[:email]).to be_empty + end + end + end + + context "#save" do + context "when associated with a registered user" do + let(:user) { double(:user, email: "test@example.com") } + + before do + allow(order).to receive_messages user: user + end + + it "should assign the email address of the user" do + order.run_callbacks(:create) + expect(order.email).to eq user.email + end + end + end + + context "in the cart state" do + it "should not validate email address" do + order.state = "cart" + order.email = nil + expect(order.errors[:email]).to be_empty + end + end +end diff --git a/spec/models/spree/order/checkout_spec.rb b/spec/models/spree/order/checkout_spec.rb index dcaa58f504..e5bcea4a2e 100644 --- a/spec/models/spree/order/checkout_spec.rb +++ b/spec/models/spree/order/checkout_spec.rb @@ -145,7 +145,7 @@ describe Spree::Order::Checkout do end end - # Regression test for #2028 + # Regression test for Spree #2028 context "when payment is not required" do before do allow(order).to receive_messages payment_required?: false @@ -209,7 +209,7 @@ describe Spree::Order::Checkout do end end - # Regression test for #3665 + # Regression test for Spree #3665 context "with only a complete step" do before do @old_checkout_flow = Spree::Order.checkout_flow diff --git a/spec/models/spree/order/payment_spec.rb b/spec/models/spree/order/payment_spec.rb new file mode 100644 index 0000000000..94087fa799 --- /dev/null +++ b/spec/models/spree/order/payment_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Spree + describe Spree::Order do + let(:order) { build(:order) } + let(:updater) { OrderManagement::Order::Updater.new(order) } + let(:bogus) { create(:bogus_payment_method, distributors: [create(:enterprise)]) } + + before do + # So that Payment#purchase! is called during processing + Spree::Config[:auto_capture] = true + + allow(order).to receive_message_chain(:line_items, :empty?).and_return(false) + allow(order).to receive_messages total: 100 + end + + it 'processes all payments' do + payment1 = create(:payment, amount: 50, payment_method: bogus) + payment2 = create(:payment, amount: 50, payment_method: bogus) + allow(order).to receive(:pending_payments).and_return([payment1, payment2]) + + order.process_payments! + updater.update_payment_state + expect(order.payment_state).to eq 'paid' + + expect(payment1).to be_completed + expect(payment2).to be_completed + end + + it 'does not go over total for order' do + payment1 = create(:payment, amount: 50, payment_method: bogus) + payment2 = create(:payment, amount: 50, payment_method: bogus) + payment3 = create(:payment, amount: 50, payment_method: bogus) + allow(order).to receive(:pending_payments).and_return([payment1, payment2, payment3]) + + order.process_payments! + updater.update_payment_state + expect(order.payment_state).to eq 'paid' + + expect(payment1).to be_completed + expect(payment2).to be_completed + expect(payment3).to be_checkout + end + + it "does not use failed payments" do + payment1 = create(:payment, amount: 50, payment_method: bogus) + payment2 = create(:payment, amount: 50, state: 'failed', payment_method: bogus) + allow(order).to receive(:pending_payments).and_return([payment1]) + + expect(payment2).not_to receive(:process!) + + order.process_payments! + end + end +end diff --git a/spec/models/spree/order/state_machine_spec.rb b/spec/models/spree/order/state_machine_spec.rb new file mode 100644 index 0000000000..f9a4164ef1 --- /dev/null +++ b/spec/models/spree/order/state_machine_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Spree::Order do + let(:order) { Spree::Order.new } + before do + # Ensure state machine has been re-defined correctly + Spree::Order.define_state_machine! + # We don't care about this validation here + allow(order).to receive(:require_email) + end + + context "#next!" do + context "when current state is payment" do + before do + order.state = "payment" + order.run_callbacks(:create) + allow(order).to receive_messages payment_required?: true + allow(order).to receive_messages process_payments!: true + allow(order).to receive :has_available_shipment + end + + context "when payment processing succeeds" do + before { allow(order).to receive_messages process_payments!: true } + + it "should finalize order when transitioning to complete state" do + expect(order).to receive(:finalize!) + order.next! + end + + context "when credit card processing fails" do + before { allow(order).to receive_messages process_payments!: false } + + it "should not complete the order" do + order.next + expect(order.state).to eq "payment" + end + end + end + + context "when payment processing fails" do + before { allow(order).to receive_messages process_payments!: false } + + it "cannot transition to complete" do + order.next + expect(order.state).to eq "payment" + end + end + end + + context "when current state is address" do + before do + allow(order).to receive(:has_available_payment) + allow(order).to receive(:ensure_available_shipping_rates) + order.state = "address" + end + + it "adjusts tax rates when transitioning to delivery" do + # Once because the record is being saved + # Twice because it is transitioning to the delivery state + expect(Spree::TaxRate).to receive(:adjust).twice + order.next! + end + end + + context "when current state is delivery" do + before do + order.state = "delivery" + allow(order).to receive_messages total: 10.0 + end + end + end + + context "#can_cancel?" do + %w(pending backorder ready).each do |shipment_state| + it "should be true if shipment_state is #{shipment_state}" do + allow(order).to receive_messages completed?: true + order.shipment_state = shipment_state + expect(order.can_cancel?).to be_truthy + end + end + + (Spree::Shipment.state_machine.states.keys - %w(pending backorder ready)).each do |shipment_state| + it "should be false if shipment_state is #{shipment_state}" do + allow(order).to receive_messages completed?: true + order.shipment_state = shipment_state + expect(order.can_cancel?).to be_falsy + end + end + end + + context "#cancel" do + let!(:variant) { build(:variant) } + let!(:inventory_units) { + [build(:inventory_unit, variant: variant), + build(:inventory_unit, variant: variant)] + } + let!(:shipment) do + shipment = build(:shipment) + allow(shipment).to receive_messages inventory_units: inventory_units + allow(order).to receive_messages shipments: [shipment] + shipment + end + + before do + allow(order).to receive_messages line_items: [build(:line_item, variant: variant, quantity: 2)] + allow(order.line_items).to receive_messages find_by_variant_id: order.line_items.first + + allow(order).to receive_messages completed?: true + allow(order).to receive_messages allow_cancel?: true + end + + it "should send a cancel email" do + # Stub methods that cause side-effects in this test + allow(shipment).to receive(:cancel!) + allow(order).to receive :has_available_shipment + allow(order).to receive :restock_items! + mail_message = double "Mail::Message" + order_id = nil + expect(Spree::OrderMailer).to receive(:cancel_email) { |*args| + order_id = args[0] + mail_message + } + expect(mail_message).to receive :deliver + order.cancel! + expect(order_id).to eq order.id + end + + context "restocking inventory" do + before do + allow(shipment).to receive(:ensure_correct_adjustment) + allow(shipment).to receive(:update_order) + allow(Spree::OrderMailer).to receive(:cancel_email).and_return(mail_message = double) + allow(mail_message).to receive :deliver + + allow(order).to receive :has_available_shipment + end + end + + context "resets payment state" do + before do + # Stubs methods that cause unwanted side effects in this test + allow(Spree::OrderMailer).to receive(:cancel_email).and_return(mail_message = double) + allow(mail_message).to receive :deliver + allow(order).to receive :has_available_shipment + allow(order).to receive :restock_items! + allow(shipment).to receive(:cancel!) + end + + context "without shipped items" do + it "should set payment state to 'credit owed'" do + order.cancel! + expect(order.payment_state).to eq 'credit_owed' + end + end + + context "with shipped items" do + before do + allow(order).to receive_messages shipment_state: 'partial' + end + + it "should not alter the payment state" do + order.cancel! + expect(order.payment_state).to be_nil + end + end + end + end + + # Another regression test for Spree #729 + context "#resume" do + before do + allow(order).to receive_messages email: "user@spreecommerce.com" + allow(order).to receive_messages state: "canceled" + allow(order).to receive_messages allow_resume?: true + + # Stubs method that cause unwanted side effects in this test + allow(order).to receive :has_available_shipment + end + end +end diff --git a/spec/models/spree/order/tax_spec.rb b/spec/models/spree/order/tax_spec.rb new file mode 100644 index 0000000000..dae075c9fa --- /dev/null +++ b/spec/models/spree/order/tax_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Spree + describe Spree::Order do + let(:order) { build(:order) } + + context "#tax_zone" do + let(:bill_address) { create :address } + let(:ship_address) { create :address } + let(:order) { Spree::Order.create(ship_address: ship_address, bill_address: bill_address) } + let(:zone) { create :zone } + + context "when no zones exist" do + before { Spree::Zone.destroy_all } + + it "should return nil" do + expect(order.tax_zone).to be_nil + end + end + + context "when :tax_using_ship_address => true" do + before { Spree::Config.set(tax_using_ship_address: true) } + + it "should calculate using ship_address" do + expect(Spree::Zone).to receive(:match).at_least(:once).with(ship_address) + expect(Spree::Zone).not_to receive(:match).with(bill_address) + order.tax_zone + end + end + + context "when :tax_using_ship_address => false" do + before { Spree::Config.set(tax_using_ship_address: false) } + + it "should calculate using bill_address" do + expect(Spree::Zone).to receive(:match).at_least(:once).with(bill_address) + expect(Spree::Zone).not_to receive(:match).with(ship_address) + order.tax_zone + end + end + + context "when there is a default tax zone" do + before do + @default_zone = create(:zone, name: "foo_zone") + allow(Spree::Zone).to receive_messages default_tax: @default_zone + end + + context "when there is a matching zone" do + before { allow(Spree::Zone).to receive_messages(match: zone) } + + it "should return the matching zone" do + expect(order.tax_zone).to eq zone + end + end + + context "when there is no matching zone" do + before { allow(Spree::Zone).to receive_messages(match: nil) } + + it "should return the default tax zone" do + expect(order.tax_zone).to eq @default_zone + end + end + end + + context "when no default tax zone" do + before { allow(Spree::Zone).to receive_messages default_tax: nil } + + context "when there is a matching zone" do + before { allow(Spree::Zone).to receive_messages(match: zone) } + + it "should return the matching zone" do + expect(order.tax_zone).to eq zone + end + end + + context "when there is no matching zone" do + before { allow(Spree::Zone).to receive_messages(match: nil) } + + it "should return nil" do + expect(order.tax_zone).to be_nil + end + end + end + end + + context "#exclude_tax?" do + before do + @order = create(:order) + @default_zone = create(:zone) + allow(Spree::Zone).to receive_messages default_tax: @default_zone + end + + context "when prices include tax" do + before { Spree::Config.set(prices_inc_tax: true) } + + it "should be true when tax_zone is not the same as the default" do + allow(@order).to receive_messages tax_zone: create(:zone, name: "other_zone") + expect(@order.exclude_tax?).to be_truthy + end + + it "should be false when tax_zone is the same as the default" do + allow(@order).to receive_messages tax_zone: @default_zone + expect(@order.exclude_tax?).to be_falsy + end + end + + context "when prices do not include tax" do + before { Spree::Config.set(prices_inc_tax: false) } + + it "should be false" do + expect(@order.exclude_tax?).to be_falsy + end + end + end + end +end diff --git a/spec/models/spree/order/updating_spec.rb b/spec/models/spree/order/updating_spec.rb new file mode 100644 index 0000000000..068fde6be9 --- /dev/null +++ b/spec/models/spree/order/updating_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Spree::Order do + let(:order) { build(:order) } + + context "#update!" do + let(:line_items) { [build(:line_item, amount: 5)] } + + context "when there are update hooks" do + before { Spree::Order.register_update_hook :foo } + after { Spree::Order.update_hooks.clear } + it "should call each of the update hooks" do + expect(order).to receive :foo + order.update! + end + end + end +end diff --git a/spec/models/spree/order_contents_spec.rb b/spec/models/spree/order_contents_spec.rb new file mode 100644 index 0000000000..7a333521ee --- /dev/null +++ b/spec/models/spree/order_contents_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Spree::OrderContents do + let(:order) { Spree::Order.create } + subject { described_class.new(order) } + + context "#add" do + let(:variant) { create(:variant) } + + context 'given quantity is not explicitly provided' do + it 'should add one line item' do + line_item = subject.add(variant) + expect(line_item.quantity).to eq 1 + expect(order.line_items.size).to eq 1 + end + end + + it 'should add line item if one does not exist' do + line_item = subject.add(variant, 1) + expect(line_item.quantity).to eq 1 + expect(order.line_items.size).to eq 1 + end + + it 'should update line item if one exists' do + subject.add(variant, 1) + line_item = subject.add(variant, 1) + expect(line_item.quantity).to eq 2 + expect(order.line_items.size).to eq 1 + end + + it "should update order totals" do + expect(order.item_total.to_f).to eq 0.00 + expect(order.total.to_f).to eq 0.00 + + subject.add(variant, 1) + + expect(order.item_total.to_f).to eq 19.99 + expect(order.total.to_f).to eq 19.99 + end + end + + context "#remove" do + let(:variant) { create(:variant) } + + context "given an invalid variant" do + it "raises an exception" do + expect { + subject.remove(variant, 1) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'given quantity is not explicitly provided' do + it 'should remove one line item' do + line_item = subject.add(variant, 3) + subject.remove(variant) + + expect(line_item.reload.quantity).to eq 2 + end + end + + it 'should reduce line_item quantity if quantity is less the line_item quantity' do + line_item = subject.add(variant, 3) + subject.remove(variant, 1) + + expect(line_item.reload.quantity).to eq 2 + end + + it 'should remove line_item if quantity matches line_item quantity' do + subject.add(variant, 1) + subject.remove(variant, 1) + + expect(order.reload.find_line_item_by_variant(variant)).to be_nil + end + + it "should update order totals" do + expect(order.item_total.to_f).to eq 0.00 + expect(order.total.to_f).to eq 0.00 + + subject.add(variant, 2) + + expect(order.item_total.to_f).to eq 39.98 + expect(order.total.to_f).to eq 39.98 + + subject.remove(variant, 1) + expect(order.item_total.to_f).to eq 19.99 + expect(order.total.to_f).to eq 19.99 + end + end +end diff --git a/spec/models/spree/order_inventory_spec.rb b/spec/models/spree/order_inventory_spec.rb new file mode 100644 index 0000000000..57df61ef14 --- /dev/null +++ b/spec/models/spree/order_inventory_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Spree::OrderInventory do + let(:order) { create :completed_order_with_totals } + let(:line_item) { order.line_items.first } + subject { described_class.new(order) } + + it 'inventory_units_for should return array of units for a given variant' do + units = subject.inventory_units_for(line_item.variant) + expect(units.map(&:variant_id)).to eq [line_item.variant.id] + end + + context "when order is missing inventory units" do + before do + line_item.update_column(:quantity, 2) + end + + it 'should be a messed up order' do + expect(order.shipments.first.inventory_units_for(line_item.variant).size).to eq 1 + expect(line_item.reload.quantity).to eq 2 + end + + it 'should increase the number of inventory units' do + subject.verify(line_item) + expect(order.reload.shipments.first.inventory_units_for(line_item.variant).size).to eq 2 + end + end + + context "#add_to_shipment" do + let(:shipment) { order.shipments.first } + let(:variant) { create :variant } + + context "order is not completed" do + before { allow(order).to receive_messages completed?: false } + + it "doesn't unstock items" do + expect(shipment.stock_location).not_to receive(:unstock) + expect(subject.send(:add_to_shipment, shipment, variant, 5)).to eq 5 + end + end + + it 'should create inventory_units in the necessary states' do + expect(shipment.stock_location).to receive(:fill_status).with(variant, 5).and_return([3, 2]) + + expect(subject.send(:add_to_shipment, shipment, variant, 5)).to eq 5 + + units = shipment.inventory_units.group_by &:variant_id + units = units[variant.id].group_by &:state + expect(units['backordered'].size).to eq 2 + expect(units['on_hand'].size).to eq 3 + end + + it 'should create stock_movement' do + expect(subject.send(:add_to_shipment, shipment, variant, 5)).to eq 5 + + stock_item = shipment.stock_location.stock_item(variant) + movement = stock_item.stock_movements.last + expect(movement.quantity).to eq(-5) + end + end + + context 'when order has too many inventory units' do + before do + line_item.quantity = 3 + line_item.save! + + line_item.update_column(:quantity, 2) + order.reload + end + + it 'should be a messed up order' do + expect(order.shipments.first.inventory_units_for(line_item.variant).size).to eq 3 + expect(line_item.quantity).to eq 2 + end + + it 'should decrease the number of inventory units' do + subject.verify(line_item) + expect(order.reload.shipments.first.inventory_units_for(line_item.variant).size).to eq 2 + end + + context '#remove_from_shipment' do + let(:shipment) { order.shipments.first } + let(:variant) { order.line_items.first.variant } + + context "order is not completed" do + before { allow(order).to receive_messages completed?: false } + + it "doesn't restock items" do + expect(shipment.stock_location).not_to receive(:restock) + expect(subject.send(:remove_from_shipment, shipment, variant, 1)).to eq 1 + end + end + + it 'should create stock_movement' do + expect(subject.send(:remove_from_shipment, shipment, variant, 1)).to eq 1 + + stock_item = shipment.stock_location.stock_item(variant) + movement = stock_item.stock_movements.last + expect(movement.quantity).to eq 1 + end + + it 'should destroy backordered units first' do + allow(shipment).to receive_messages(inventory_units_for: [build(:inventory_unit, variant_id: variant.id, state: 'backordered'), + build(:inventory_unit, variant_id: variant.id, state: 'on_hand'), + build(:inventory_unit, variant_id: variant.id, state: 'backordered')]) + + expect(shipment.inventory_units_for[0]).to receive(:destroy) + expect(shipment.inventory_units_for[1]).not_to receive(:destroy) + expect(shipment.inventory_units_for[2]).to receive(:destroy) + + expect(subject.send(:remove_from_shipment, shipment, variant, 2)).to eq 2 + end + + it 'should destroy unshipped units first' do + allow(shipment).to receive_messages(inventory_units_for: [build(:inventory_unit, variant_id: variant.id, state: 'shipped'), + build(:inventory_unit, variant_id: variant.id, state: 'on_hand')] ) + + expect(shipment.inventory_units_for[0]).not_to receive(:destroy) + expect(shipment.inventory_units_for[1]).to receive(:destroy) + + expect(subject.send(:remove_from_shipment, shipment, variant, 1)).to eq 1 + end + + it 'only attempts to destroy as many units as are eligible, and return amount destroyed' do + allow(shipment).to receive_messages(inventory_units_for: [build(:inventory_unit, variant_id: variant.id, state: 'shipped'), + build(:inventory_unit, variant_id: variant.id, state: 'on_hand')] ) + + expect(shipment.inventory_units_for[0]).not_to receive(:destroy) + expect(shipment.inventory_units_for[1]).to receive(:destroy) + + expect(subject.send(:remove_from_shipment, shipment, variant, 1)).to eq 1 + end + + it 'should destroy self if not inventory units remain' do + allow(shipment.inventory_units).to receive_messages(count: 0) + expect(shipment).to receive(:destroy) + + expect(subject.send(:remove_from_shipment, shipment, variant, 1)).to eq 1 + end + end + end +end diff --git a/spec/models/spree/order_spec.rb b/spec/models/spree/order_spec.rb index 608e6c0eca..88d285c99a 100644 --- a/spec/models/spree/order_spec.rb +++ b/spec/models/spree/order_spec.rb @@ -3,11 +3,498 @@ require 'spec_helper' describe Spree::Order do include OpenFoodNetwork::EmailHelper + let(:user) { build(:user, email: "spree@example.com") } + let(:order) { build(:order, user: user) } + + context "#products" do + let(:order) { create(:order_with_line_items) } + + it "should return ordered products" do + expect(order.products.first).to eq order.line_items.first.product + end + + it "contains?" do + expect(order.contains?(order.line_items.first.variant)).to be_truthy + end + + it "can find a line item matching a given variant" do + expect(order.find_line_item_by_variant(order.line_items.third.variant)).to_not be_nil + expect(order.find_line_item_by_variant(build(:variant))).to be_nil + end + end + + context "#generate_order_number" do + it "should generate a random string" do + expect(order.generate_order_number.is_a?(String)).to be_truthy + expect((!order.generate_order_number.to_s.empty?)).to be_truthy + end + end + + context "#associate_user!" do + it "should associate a user with a persisted order" do + order = create(:order_with_line_items, created_by: nil) + user = create(:user) + + order.user = nil + order.email = nil + order.associate_user!(user) + expect(order.user).to eq user + expect(order.email).to eq user.email + expect(order.created_by).to eq user + + # verify that the changes we made were persisted + order.reload + expect(order.user).to eq user + expect(order.email).to eq user.email + expect(order.created_by).to eq user + end + + it "should not overwrite the created_by if it already is set" do + creator = create(:user) + order = create(:order_with_line_items, created_by: creator) + user = create(:user) + + order.user = nil + order.email = nil + order.associate_user!(user) + expect(order.user).to eq user + expect(order.email).to eq user.email + expect(order.created_by).to eq creator + + # verify that the changes we made were persisted + order.reload + expect(order.user).to eq user + expect(order.email).to eq user.email + expect(order.created_by).to eq creator + end + + it "should associate a user with a non-persisted order" do + order = Spree::Order.new + + expect do + order.associate_user!(user) + end.to change { [order.user, order.email] }.from([nil, nil]).to([user, user.email]) + end + + it "should not persist an invalid address" do + address = Spree::Address.new + order.user = nil + order.email = nil + order.ship_address = address + expect do + order.associate_user!(user) + end.not_to change { address.persisted? }.from(false) + end + end + + context "#create" do + it "should assign an order number" do + order = Spree::Order.create + expect(order.number).to_not be_nil + end + end + + context "#can_ship?" do + let(:order) { Spree::Order.create } + + it "should be true for order in the 'complete' state" do + allow(order).to receive_messages(complete?: true) + expect(order.can_ship?).to be_truthy + end + + it "should be true for order in the 'resumed' state" do + allow(order).to receive_messages(resumed?: true) + expect(order.can_ship?).to be_truthy + end + + it "should be true for an order in the 'awaiting return' state" do + allow(order).to receive_messages(awaiting_return?: true) + expect(order.can_ship?).to be_truthy + end + + it "should be true for an order in the 'returned' state" do + allow(order).to receive_messages(returned?: true) + expect(order.can_ship?).to be_truthy + end + + it "should be false if the order is neither in the 'complete' nor 'resumed' state" do + allow(order).to receive_messages(resumed?: false, complete?: false) + expect(order.can_ship?).to be_falsy + end + end + + context "checking if order is paid" do + context "payment_state is paid" do + before { allow(order).to receive_messages payment_state: 'paid' } + it { expect(order).to be_paid } + end + + context "payment_state is credit_owned" do + before { allow(order).to receive_messages payment_state: 'credit_owed' } + it { expect(order).to be_paid } + end + end + + context "#finalize!" do + let(:order) { Spree::Order.create } + it "should set completed_at" do + expect(order).to receive(:touch).with(:completed_at) + order.finalize! + end + + it "should sell inventory units" do + order.shipments.each do |shipment| + expect(shipment).to receive(:update!) + expect(shipment).to receive(:finalize!) + end + order.finalize! + end + + it "should decrease the stock for each variant in the shipment" do + order.shipments.each do |shipment| + expect(shipment.stock_location).to receive(:decrease_stock_for_variant) + end + order.finalize! + end + + it "should change the shipment state to ready if order is paid" do + Spree::Shipment.create(order: order) + order.shipments.reload + + allow(order).to receive_messages(paid?: true, complete?: true) + order.finalize! + order.reload # reload so we're sure the changes are persisted + expect(order.shipment_state).to eq 'ready' + end + + it "should send an order confirmation email" do + expect do + order.finalize! + end.to enqueue_job ConfirmOrderJob + end + + it "should freeze all adjustments" do + # Stub this method as it's called due to a callback + # and it's irrelevant to this test + allow(order).to receive :has_available_shipment + allow(Spree::OrderMailer).to receive_message_chain :confirm_email, :deliver + adjustments = double + allow(order).to receive_messages adjustments: adjustments + expect(adjustments).to receive(:update_all).with(state: 'closed') + order.finalize! + end + + it "should log state event" do + expect(order.state_changes).to receive(:create).exactly(3).times # order, shipment & payment state changes + order.finalize! + end + + it 'calls updater#before_save' do + expect(order.updater).to receive(:before_save_hook) + order.finalize! + end + end + + context "#process_payments!" do + let(:payment) { build(:payment) } + before { allow(order).to receive_messages pending_payments: [payment], total: 10 } + + it "should process the payments" do + expect(payment).to receive(:process!) + expect(order.process_payments!).to be_truthy + end + + it "should return false if no pending_payments available" do + allow(order).to receive_messages pending_payments: [] + expect(order.process_payments!).to be_falsy + end + + context "when a payment raises a GatewayError" do + before { expect(payment).to receive(:process!).and_raise(Spree::Core::GatewayError) } + + it "should return true when configured to allow checkout on gateway failures" do + Spree::Config.set allow_checkout_on_gateway_error: true + expect(order.process_payments!).to be_truthy + end + + it "should return false when not configured to allow checkout on gateway failures" do + Spree::Config.set allow_checkout_on_gateway_error: false + expect(order.process_payments!).to be_falsy + end + end + end + + context "#outstanding_balance" do + it "should return positive amount when payment_total is less than total" do + order.payment_total = 20.20 + order.total = 30.30 + expect(order.outstanding_balance).to eq 10.10 + end + it "should return negative amount when payment_total is greater than total" do + order.total = 8.20 + order.payment_total = 10.20 + expect(order.outstanding_balance).to be_within(0.001).of(-2.00) + end + end + + context "#outstanding_balance?" do + it "should be true when total greater than payment_total" do + order.total = 10.10 + order.payment_total = 9.50 + expect(order.outstanding_balance?).to be_truthy + end + it "should be true when total less than payment_total" do + order.total = 8.25 + order.payment_total = 10.44 + expect(order.outstanding_balance?).to be_truthy + end + it "should be false when total equals payment_total" do + order.total = 10.10 + order.payment_total = 10.10 + expect(order.outstanding_balance?).to be_falsy + end + end + + context "#completed?" do + it "should indicate if order is completed" do + order.completed_at = nil + expect(order.completed?).to be_falsy + + order.completed_at = Time.zone.now + expect(order.completed?).to be_truthy + end + end + + context "#allow_checkout?" do + it "should be true if there are line_items in the order" do + allow(order).to receive_message_chain(:line_items, count: 1) + expect(order.checkout_allowed?).to be_truthy + end + it "should be false if there are no line_items in the order" do + allow(order).to receive_message_chain(:line_items, count: 0) + expect(order.checkout_allowed?).to be_falsy + end + end + + context "#item_count" do + before do + @order = create(:order, user: user) + @order.line_items = [create(:line_item, quantity: 2), create(:line_item, quantity: 1)] + end + it "should return the correct number of items" do + expect(@order.item_count).to eq 3 + end + end + + context "#amount" do + before do + @order = create(:order, user: user) + @order.line_items = [create(:line_item, price: 1.0, quantity: 2), + create(:line_item, price: 1.0, quantity: 1)] + end + it "should return the correct lum sum of items" do + expect(@order.amount).to eq 3.0 + end + end + + context "#can_cancel?" do + it "should be false for completed order in the canceled state" do + order.state = 'canceled' + order.shipment_state = 'ready' + order.completed_at = Time.zone.now + expect(order.can_cancel?).to be_falsy + end + + it "should be true for completed order with no shipment" do + order.state = 'complete' + order.shipment_state = nil + order.completed_at = Time.zone.now + expect(order.can_cancel?).to be_truthy + end + end + + context "insufficient_stock_lines" do + let(:line_item) { build(:line_item) } + + before do + allow(order).to receive_messages(line_items: [line_item]) + allow(line_item).to receive(:insufficient_stock?) { true } + end + + it "should return line_item that has insufficient stock on hand" do + expect(order.insufficient_stock_lines.size).to eq 1 + expect(order.insufficient_stock_lines.include?(line_item)).to be_truthy + end + end + + context "empty!" do + it "should clear out all line items and adjustments" do + order = build(:order) + allow(order).to receive_messages(line_items: line_items = []) + allow(order).to receive_messages(adjustments: adjustments = []) + expect(order.line_items).to receive(:destroy_all) + expect(order.adjustments).to receive(:destroy_all) + + order.empty! + end + end + + context "#display_outstanding_balance" do + it "returns the value as a spree money" do + allow(order).to receive(:outstanding_balance) { 10.55 } + expect(order.display_outstanding_balance).to eq Spree::Money.new(10.55) + end + end + + context "#display_item_total" do + it "returns the value as a spree money" do + allow(order).to receive(:item_total) { 10.55 } + expect(order.display_item_total).to eq Spree::Money.new(10.55) + end + end + + context "#display_adjustment_total" do + it "returns the value as a spree money" do + order.adjustment_total = 10.55 + expect(order.display_adjustment_total).to eq Spree::Money.new(10.55) + end + end + + context "#display_total" do + it "returns the value as a spree money" do + order.total = 10.55 + expect(order.display_total).to eq Spree::Money.new(10.55) + end + end + + context "#currency" do + context "when object currency is ABC" do + before { order.currency = "ABC" } + + it "returns the currency from the object" do + expect(order.currency).to eq "ABC" + end + end + + context "when object currency is nil" do + before { order.currency = nil } + + it "returns the globally configured currency" do + expect(order.currency).to eq Spree::Config[:currency] + end + end + end + + # Regression test for Spree #2191 + context "when an order has an adjustment that zeroes the total, but another adjustment for shipping that raises it above zero" do + let!(:persisted_order) { create(:order) } + let!(:line_item) { create(:line_item) } + let!(:shipping_method) do + sm = create(:shipping_method) + sm.calculator.preferred_amount = 10 + sm.save + sm + end + + before do + # Don't care about available payment methods in this test + allow(persisted_order).to receive_messages(has_available_payment: false) + persisted_order.line_items << line_item + persisted_order.adjustments.create(amount: -line_item.amount, label: "Promotion") + persisted_order.state = 'delivery' + persisted_order.save # To ensure new state_change event + end + + it "transitions from delivery to payment" do + allow(persisted_order).to receive_messages(payment_required?: true) + persisted_order.next! + expect(persisted_order.state).to eq "payment" + end + end + + context "payment required?" do + let(:order) { Spree::Order.new } + + context "total is zero" do + it { expect(order.payment_required?).to be_falsy } + end + + context "total > zero" do + before { allow(order).to receive_messages(total: 1) } + it { expect(order.payment_required?).to be_truthy } + end + end + + context "add_update_hook" do + before do + Spree::Order.class_eval do + register_update_hook :add_awesome_sauce + end + end + + after do + Spree::Order.update_hooks = Set.new + end + + it "calls hook during update" do + order = create(:order) + expect(order).to receive(:add_awesome_sauce) + order.update! + end + + it "calls hook during finalize" do + order = create(:order) + expect(order).to receive(:add_awesome_sauce) + order.finalize! + end + end + + context "ensure shipments will be updated" do + before { Spree::Shipment.create!(order: order) } + + it "destroys current shipments" do + order.ensure_updated_shipments + expect(order.shipments).to be_empty + end + + it "puts order back in address state" do + order.ensure_updated_shipments + expect(order.state).to eql "address" + end + end + + describe ".tax_address" do + before { Spree::Config[:tax_using_ship_address] = tax_using_ship_address } + subject { order.tax_address } + + context "when tax_using_ship_address is true" do + let(:tax_using_ship_address) { true } + + it 'returns ship_address' do + expect(subject).to eq order.ship_address + end + end + + context "when tax_using_ship_address is not true" do + let(:tax_using_ship_address) { false } + + it "returns bill_address" do + expect(subject).to eq order.bill_address + end + end + end + + context '#updater' do + it 'returns an OrderManagement::Order::Updater' do + expect(order.updater.class).to eq OrderManagement::Order::Updater + end + end + describe "email validation" do let(:order) { build(:order) } it "has errors if email is blank" do - order.stub(require_email: true) + allow(order).to receive_messages(require_email: true) order.email = "" order.valid? @@ -15,7 +502,7 @@ describe Spree::Order do end it "has errors if email is invalid" do - order.stub(require_email: true) + allow(order).to receive_messages(require_email: true) order.email = "invalid_email" order.valid? @@ -23,7 +510,7 @@ describe Spree::Order do end it "has errors if email has invalid domain" do - order.stub(require_email: true) + allow(order).to receive_messages(require_email: true) order.email = "single_letter_tld@domain.z" order.valid? @@ -31,7 +518,7 @@ describe Spree::Order do end it "is valid if email is valid" do - order.stub(require_email: true) + allow(order).to receive_messages(require_email: true) order.email = "a@b.ca" order.valid? diff --git a/spec/models/spree/return_authorization_spec.rb b/spec/models/spree/return_authorization_spec.rb new file mode 100644 index 0000000000..d37bac00ed --- /dev/null +++ b/spec/models/spree/return_authorization_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Spree::ReturnAuthorization do + let(:order) { create(:shipped_order) } + let(:variant) { order.shipments.first.inventory_units.first.variant } + let(:return_authorization) { Spree::ReturnAuthorization.new(order: order) } + + context "save" do + it "should be invalid when order has no inventory units" do + order.shipments.destroy_all + return_authorization.save + expect(return_authorization.errors[:order]).to eq ["has no shipped units"] + end + + it "should generate RMA number" do + expect(return_authorization).to receive(:generate_number) + return_authorization.save + end + end + + context "add_variant" do + context "on empty rma" do + it "should associate inventory unit" do + return_authorization.add_variant(variant.id, 1) + expect(return_authorization.inventory_units.size).to eq 1 + end + + it "should associate inventory units as shipped" do + return_authorization.add_variant(variant.id, 1) + expect(return_authorization.inventory_units.where(state: 'shipped').size).to eq 1 + end + + it "should update order state" do + expect(order).to receive(:authorize_return!) + return_authorization.add_variant(variant.id, 1) + end + end + + context "on rma that already has inventory_units" do + before do + return_authorization.add_variant(variant.id, 1) + end + + it "should not associate more inventory units than there are on the order" do + return_authorization.add_variant(variant.id, 1) + expect(return_authorization.inventory_units.size).to eq 1 + end + + it "should not update order state" do + expect{ return_authorization.add_variant(variant.id, 1) }.to_not change{ order.state } + end + end + end + + context "can_receive?" do + it "should allow_receive when inventory units assigned" do + allow(return_authorization).to receive_messages(inventory_units: [1, 2, 3]) + expect(return_authorization.can_receive?).to be_truthy + end + + it "should not allow_receive with no inventory units" do + allow(return_authorization).to receive_messages(inventory_units: []) + expect(return_authorization.can_receive?).to be_falsy + end + end + + context "receive!" do + let(:inventory_unit) { order.shipments.first.inventory_units.first } + + before do + allow(return_authorization).to receive_messages(inventory_units: [inventory_unit], amount: -20) + allow(Spree::Adjustment).to receive(:create) + allow(order).to receive(:update!) + end + + it "should mark all inventory units are returned" do + expect(inventory_unit).to receive(:return!) + return_authorization.receive! + end + + it "should add credit for specified amount" do + return_authorization.amount = 20 + mock_adjustment = double + expect(mock_adjustment).to receive(:source=).with(return_authorization) + expect(mock_adjustment).to receive(:adjustable=).with(order) + expect(mock_adjustment).to receive(:save) + expect(Spree::Adjustment).to receive(:new).with(amount: -20, label: Spree.t(:rma_credit)).and_return(mock_adjustment) + return_authorization.receive! + end + + it "should update order state" do + expect(order).to receive :update! + return_authorization.receive! + end + end + + context "force_positive_amount" do + it "should ensure the amount is always positive" do + return_authorization.amount = -10 + return_authorization.send :force_positive_amount + expect(return_authorization.amount).to eq 10 + end + end + + context "after_save" do + it "should run correct callbacks" do + expect(return_authorization).to receive(:force_positive_amount) + return_authorization.run_callbacks(:save) + end + end + + context "currency" do + before { allow(order).to receive(:currency) { "ABC" } } + it "returns the order currency" do + expect(return_authorization.currency).to eq "ABC" + end + end + + context "display_amount" do + it "returns a Spree::Money" do + return_authorization.amount = 21.22 + expect(return_authorization.display_amount).to eq Spree::Money.new(21.22) + end + end + + context "returnable_inventory" do + pending "should return inventory from shipped shipments" do + expect(return_authorization.returnable_inventory).to eq [inventory_unit] + end + + pending "should not return inventory from unshipped shipments" do + expect(return_authorization.returnable_inventory).to eq [] + end + end +end