From 47d2f698ef273e8978bfa2b43bc06d31319cdbc7 Mon Sep 17 00:00:00 2001 From: Luis Ramos Date: Sat, 8 Aug 2020 14:44:30 +0100 Subject: [PATCH] Bring models related to Order from spree_core EPIC COMMIT ALERT :-) --- app/models/spree/inventory_unit.rb | 70 ++ app/models/spree/line_item.rb | 102 +++ app/models/spree/order.rb | 575 ++++++++++++++++ app/models/spree/order_contents.rb | 67 ++ app/models/spree/order_inventory.rb | 106 +++ app/models/spree/return_authorization.rb | 103 +++ app/models/spree/state_change.rb | 15 + app/models/spree/tokenized_permission.rb | 6 + spec/models/spree/inventory_unit_spec.rb | 85 +++ spec/models/spree/line_item_spec.rb | 137 ++++ spec/models/spree/order/address_spec.rb | 50 ++ spec/models/spree/order/adjustments_spec.rb | 148 +++++ spec/models/spree/order/callbacks_spec.rb | 42 ++ spec/models/spree/order/checkout_spec.rb | 4 +- spec/models/spree/order/payment_spec.rb | 54 ++ spec/models/spree/order/state_machine_spec.rb | 183 ++++++ spec/models/spree/order/tax_spec.rb | 115 ++++ spec/models/spree/order/updating_spec.rb | 18 + spec/models/spree/order_contents_spec.rb | 92 +++ spec/models/spree/order_inventory_spec.rb | 174 +++++ spec/models/spree/order_spec.rb | 611 ++++++++++++++++++ .../models/spree/return_authorization_spec.rb | 138 ++++ 22 files changed, 2893 insertions(+), 2 deletions(-) create mode 100644 app/models/spree/inventory_unit.rb create mode 100644 app/models/spree/line_item.rb create mode 100644 app/models/spree/order.rb create mode 100644 app/models/spree/order_contents.rb create mode 100644 app/models/spree/order_inventory.rb create mode 100644 app/models/spree/return_authorization.rb create mode 100644 app/models/spree/state_change.rb create mode 100644 app/models/spree/tokenized_permission.rb create mode 100644 spec/models/spree/inventory_unit_spec.rb create mode 100644 spec/models/spree/order/address_spec.rb create mode 100644 spec/models/spree/order/adjustments_spec.rb create mode 100644 spec/models/spree/order/callbacks_spec.rb create mode 100644 spec/models/spree/order/payment_spec.rb create mode 100644 spec/models/spree/order/state_machine_spec.rb create mode 100644 spec/models/spree/order/tax_spec.rb create mode 100644 spec/models/spree/order/updating_spec.rb create mode 100644 spec/models/spree/order_contents_spec.rb create mode 100644 spec/models/spree/order_inventory_spec.rb create mode 100644 spec/models/spree/return_authorization_spec.rb diff --git a/app/models/spree/inventory_unit.rb b/app/models/spree/inventory_unit.rb new file mode 100644 index 0000000000..7380bc40ec --- /dev/null +++ b/app/models/spree/inventory_unit.rb @@ -0,0 +1,70 @@ +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("#{self.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.where(stock_location_id: shipment.stock_location_id, + variant_id: variant_id).first + end + + # Remove variant default_scope `deleted_at: nil` + def variant + Spree::Variant.unscoped { super } + end + + private + + def allow_ship? + Spree::Config[:allow_backorder_shipping] || self.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..2e160ba018 --- /dev/null +++ b/app/models/spree/line_item.rb @@ -0,0 +1,102 @@ +module Spree + class LineItem < ActiveRecord::Base + before_validation :adjust_quantity + belongs_to :order, class_name: "Spree::Order" + 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 + + 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 + + after_save :update_order + after_destroy :update_order + + attr_accessor :target_shipment + + def copy_price + if 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 + end + + def copy_tax_category + if variant + self.tax_category = variant.product.tax_category + end + 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 + + def sufficient_stock? + Stock::Quantifier.new(variant_id).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 + + # Remove variant default_scope `deleted_at: nil` + def variant + Spree::Variant.unscoped { super } + end + + private + def update_inventory + if changed? + Spree::OrderInventory.new(self.order).verify(self, target_shipment) + end + end + + def update_order + if changed? || destroyed? + # update the order totals, etc. + order.create_tax_charge! + order.update! + end + end + end +end + diff --git a/app/models/spree/order.rb b/app/models/spree/order.rb new file mode 100644 index 0000000000..b1056f3479 --- /dev/null +++ b/app/models/spree/order.rb @@ -0,0 +1,575 @@ +require 'spree/core/validators/email' +require 'spree/order/checkout' + +module Spree + class Order < ActiveRecord::Base + 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 :confirm, if: ->(order) { order.confirmation_required? } + go_to_state :complete + remove_transition from: :delivery, to: :confirm + end + + token_resource + + attr_reader :coupon_code + + if Spree.user_class + belongs_to :user, class_name: Spree.user_class.to_s + belongs_to :created_by, class_name: Spree.user_class.to_s + else + belongs_to :user + belongs_to :created_by + end + + 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("#{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 + + 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 + + # 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? + attr_accessor :use_billing + + before_create :link_by_email + after_create :create_tax_charge! + + validates :email, presence: true, if: :require_email + validates :email, email: true, if: :require_email, allow_blank: true + validate :has_available_shipment + validate :has_available_payment + + make_permalink field: :number + + class_attribute :update_hooks + self.update_hooks = Set.new + + 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) + self.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 + + # Is this a free order in which case the payment step should be skipped + def payment_required? + total.to_f > 0.0 + end + + # If true, causes the confirmation step to happen during the checkout process + def confirmation_required? + payments.map(&:payment_method).compact.any?(&:payment_profiles_supported?) + 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] + return 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[self.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 ||= Spree::Config.order_updater_decorator.new( + Spree::OrderUpdater.new(self) + ) + end + + def update! + updater.update + end + + def update_totals + updater.update_totals + end + + def clone_billing_address + if bill_address and self.ship_address.nil? + self.ship_address = bill_address.clone + else + self.ship_address.attributes = bill_address.attributes.except('id', 'updated_at', 'created_at') + end + true + end + + def allow_cancel? + return false unless completed? and 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? { |return_authorization| return_authorization.authorized? } + end + + def contents + @contents ||= Spree::OrderContents.new(self) + end + + # Associates the specified user with the order. + def associate_user!(user) + self.user = user + self.email = user.email + self.created_by = user if self.created_by.blank? + + if persisted? + # immediately persist the changes we just made, but don't use save since we might have an invalid address associated + self.class.unscoped.where(id: id).update_all(email: user.email, user_id: user.id, created_by_id: self.created_by_id) + end + 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.where(number: random).first + end + self.number = random if self.number.blank? + self.number + end + + def shipped_shipments + shipments.shipped + end + + def contains?(variant) + find_line_item_by_variant(variant).present? + end + + def quantity_of(variant) + line_item = find_line_item_by_variant(variant) + line_item ? line_item.quantity : 0 + 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? + self.outstanding_balance != 0 + end + + def name + if (address = bill_address || ship_address) + "#{address.firstname} #{address.lastname}" + end + end + + def can_ship? + self.complete? || self.resumed? || self.awaiting_return? || self.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 + + # lock all adjustments (coupon promotions, etc.) + 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 + + self.state_changes.create( + previous_state: 'cart', + next_state: 'complete', + name: 'order' , + user_id: self.user_id + ) + end + + def deliver_order_confirmation_email + begin + OrderMailer.confirm_email(self.id).deliver + rescue Exception => e + logger.error("#{e.class.name}: #{e.message}") + logger.error(e.backtrace * "\n") + end + 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 + + def pending_payments + payments.select(&:checkout?) + 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! + if pending_payments.empty? + raise Core::GatewayError.new Spree.t(:no_pending_payments) + else + pending_payments.each do |payment| + break if payment_total >= total + + payment.process! + + if payment.completed? + self.payment_total += payment.amount + end + end + end + rescue Core::GatewayError => e + result = !!Spree::Config[:allow_checkout_on_gateway_error] + errors.add(:base, e.message) and 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 merge!(order) + order.line_items.each do |line_item| + next unless line_item.currency == currency + current_line_item = self.line_items.find_by(variant: line_item.variant) + if current_line_item + current_line_item.quantity += line_item.quantity + current_line_item.save + else + line_item.order_id = self.id + line_item.save + end + end + # So that the destroy doesn't take out line items which may have been re-assigned + order.line_items.reload + order.destroy + end + + def empty! + line_items.destroy_all + adjustments.destroy_all + end + + def clear_adjustments! + self.adjustments.destroy_all + self.line_item_adjustments.destroy_all + end + + def has_step?(step) + checkout_steps.include?(step) + end + + def state_changed(name) + state = "#{name}_state" + if persisted? + old_state = self.send("#{state}_was") + self.state_changes.create( + previous_state: old_state, + next_state: self.send(state), + name: name, + user_id: self.user_id + ) + end + end + + def coupon_code=(code) + @coupon_code = code.strip.downcase rescue nil + end + + # Tells us if there if the specified promotion is already associated with the order + # regardless of whether or not its currently eligible. Useful because generally + # you would only want a promotion action to apply to order no more than once. + # + # Receives an adjustment +originator+ (here a PromotionAction object) and tells + # if the order has adjustments from that already + def promotion_credit_exists?(originator) + !! adjustments.includes(:originator).promotion.reload.detect { |credit| credit.originator.id == originator.id } + end + + def promo_total + adjustments.eligible.promotion.map(&:amount).sum + end + + def shipped? + %w(partial shipped).include?(shipment_state) + end + + def create_proposed_shipments + adjustments.shipping.delete_all + shipments.destroy_all + + packages = Spree::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 + if shipments.any? + self.shipments.destroy_all + self.update_column(:state, "address") + end + end + + def refresh_shipment_rates + shipments.map &:refresh_rates + end + + private + + def link_by_email + self.email = user.email if self.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? or state == 'cart' + end + + def ensure_line_items_present + unless line_items.present? + errors.add(:base, Spree.t(:there_are_no_items_for_this_order)) and return false + end + end + + def has_available_shipment + return unless has_step?("delivery") + return unless address? + return unless ship_address && ship_address.valid? + # errors.add(:base, :no_shipping_methods_available) if available_shipping_methods.empty? + end + + def ensure_available_shipping_rates + if shipments.empty? || shipments.any? { |shipment| shipment.shipping_rates.blank? } + errors.add(:base, Spree.t(:items_cannot_be_shipped)) and return false + end + 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 { |shipment| shipment.cancel! } + + OrderMailer.cancel_email(self.id).deliver + self.payment_state = 'credit_owed' unless shipped? + end + + def after_resume + shipments.each { |shipment| shipment.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 + end +end diff --git a/app/models/spree/order_contents.rb b/app/models/spree/order_contents.rb new file mode 100644 index 0000000000..c629545578 --- /dev/null +++ b/app/models/spree/order_contents.rb @@ -0,0 +1,67 @@ +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_inventory.rb b/app/models/spree/order_inventory.rb new file mode 100644 index 0000000000..d04db006e9 --- /dev/null +++ b/app/models/spree/order_inventory.rb @@ -0,0 +1,106 @@ +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) unless shipment + 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 |shipment| + break if quantity == 0 + quantity -= remove_from_shipment(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) + shipment = order.shipments.detect do |shipment| + (shipment.ready? || shipment.pending?) && shipment.include?(variant) + end + + 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..0a98f55fec --- /dev/null +++ b/app/models/spree/return_authorization.rb @@ -0,0 +1,103 @@ +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.size > 0 && !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 + errors.add(:order, Spree.t(:has_no_shipped_units)) if order.nil? || !order.shipped_shipments.any? + end + + def generate_number + return if number + + record = true + while record + random = "RMA#{Array.new(9){rand(9)}.join}" + record = self.class.where(number: random).first + 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..8954dffe81 --- /dev/null +++ b/app/models/spree/state_change.rb @@ -0,0 +1,15 @@ +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..29cc24e8b4 --- /dev/null +++ b/app/models/spree/tokenized_permission.rb @@ -0,0 +1,6 @@ +module Spree + class TokenizedPermission < ActiveRecord::Base + belongs_to :permissable, polymorphic: true + end +end + diff --git a/spec/models/spree/inventory_unit_spec.rb b/spec/models/spree/inventory_unit_spec.rb new file mode 100644 index 0000000000..2beacf1976 --- /dev/null +++ b/spec/models/spree/inventory_unit_spec.rb @@ -0,0 +1,85 @@ +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 + shipment.stub(: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 #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 + Spree::InventoryUnit.backordered_for_stock_item(stock_item).should =~ [unit] + end + + it "does not find inventory units that aren't backordered" do + on_hand_unit = shipment.inventory_units.build + on_hand_unit.state = 'on_hand' + on_hand_unit.variant_id = 1 + on_hand_unit.save! + + Spree::InventoryUnit.backordered_for_stock_item(stock_item).should_not include(on_hand_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! + + Spree::InventoryUnit.backordered_for_stock_item(stock_item).should_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 + + it "can still fetch variants by eager loading (remove default_scope)" do + unit.variant.destroy + expect(Spree::InventoryUnit.joins(:variant).includes(:variant).first.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) + inventory_units.any?(&:pending).should be_false + end + end +end diff --git a/spec/models/spree/line_item_spec.rb b/spec/models/spree/line_item_spec.rb index 4410a00975..65d1924dd5 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 + line_item.order.should_receive(:create_tax_charge!) + line_item.order.should_receive(:update!) + line_item.quantity = 2 + line_item.save + end + end + + context '#destroy' do + # Regression test for Spree #1481 + it "applies tax adjustments" do + line_item.order.should_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 + line_item.price.should == variant.price + line_item.cost_price.should == variant.cost_price + line_item.currency.should == 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 + line_item.tax_category.should == 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 + line_item.money.to_s.should == "$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 + line_item.single_money.to_s.should == "$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).to have(0).errors_on(:quantity) + 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).to have(1).errors_on(:quantity) + 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).to have(0).errors_on(:quantity) + 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).to have(1).errors_on(:quantity) + 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..2efbc20be6 --- /dev/null +++ b/spec/models/spree/order/address_spec.rb @@ -0,0 +1,50 @@ +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 = stub_model(Spree::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? + order.ship_address.should == 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? + order.ship_address.should == 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? + order.ship_address.should == 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? + order.ship_address.should 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..d35945b3c5 --- /dev/null +++ b/spec/models/spree/order/adjustments_spec.rb @@ -0,0 +1,148 @@ +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 + order.stub(:adjustments => adjustment) + adjustment.should_receive(:destroy_all) + order.clear_adjustments! + end + + it "destroy all line item adjustments" do + order.stub(:line_item_adjustments => adjustment) + adjustment.should_receive(:destroy_all) + order.clear_adjustments! + end + end + + context "totaling adjustments" do + let(:adjustment1) { mock_model(Spree::Adjustment, :amount => 5) } + let(:adjustment2) { mock_model(Spree::Adjustment, :amount => 10) } + + context "#ship_total" do + it "should return the correct amount" do + order.stub_chain :adjustments, :shipping => [adjustment1, adjustment2] + order.ship_total.should == 15 + end + end + + context "#tax_total" do + it "should return the correct amount" do + order.stub_chain :adjustments, :tax => [adjustment1, adjustment2] + order.tax_total.should == 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 { @order.stub_chain(:line_item_adjustments, :eligible => []) } + + it "should return an empty hash" do + @order.line_item_adjustment_totals.should == {} + end + end + + context "when there are two adjustments with different labels" do + let(:adj1) { mock_model Spree::Adjustment, :amount => 10, :label => "Foo" } + let(:adj2) { mock_model Spree::Adjustment, :amount => 20, :label => "Bar" } + + before do + @order.stub_chain(:line_item_adjustments, :eligible => [adj1, adj2]) + end + + it "should return exactly two totals" do + @order.line_item_adjustment_totals.size.should == 2 + end + + it "should return the correct totals" do + @order.line_item_adjustment_totals["Foo"].should == Spree::Money.new(10) + @order.line_item_adjustment_totals["Bar"].should == Spree::Money.new(20) + end + end + + context "when there are two adjustments with one label and a single adjustment with another" do + let(:adj1) { mock_model Spree::Adjustment, :amount => 10, :label => "Foo" } + let(:adj2) { mock_model Spree::Adjustment, :amount => 20, :label => "Bar" } + let(:adj3) { mock_model Spree::Adjustment, :amount => 40, :label => "Bar" } + + before do + @order.stub_chain(:line_item_adjustments, :eligible => [adj1, adj2, adj3]) + end + + it "should return exactly two totals" do + @order.line_item_adjustment_totals.size.should == 2 + end + it "should return the correct totals" do + @order.line_item_adjustment_totals["Foo"].should == Spree::Money.new(10) + @order.line_item_adjustment_totals["Bar"].should == Spree::Money.new(60) + end + end + end + + context "line item adjustments" do + before do + @order = Spree::Order.create! + @order.stub :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 + @order.line_item_adjustments.should 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 + @order.line_item_adjustments.should =~ [@adj1, @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..2743242a75 --- /dev/null +++ b/spec/models/spree/order/callbacks_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Spree::Order do + let(:order) { stub_model(Spree::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" + order.should have(:no).error_on(:email) + end + end + end + + context "#save" do + context "when associated with a registered user" do + let(:user) { double(:user, :email => "test@example.com") } + + before do + order.stub :user => user + end + + it "should assign the email address of the user" do + order.run_callbacks(:create) + order.email.should == user.email + end + end + end + + context "in the cart state" do + it "should not validate email address" do + order.state = "cart" + order.email = nil + order.should have(:no).error_on(:email) + end + end +end diff --git a/spec/models/spree/order/checkout_spec.rb b/spec/models/spree/order/checkout_spec.rb index 1020fb0fe5..f25df0b1d4 100644 --- a/spec/models/spree/order/checkout_spec.rb +++ b/spec/models/spree/order/checkout_spec.rb @@ -147,7 +147,7 @@ describe Spree::Order 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 @@ -211,7 +211,7 @@ describe Spree::Order 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..8c53b45d70 --- /dev/null +++ b/spec/models/spree/order/payment_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +module Spree + describe Spree::Order do + let(:order) { stub_model(Spree::Order) } + let(:updater) { Spree::OrderUpdater.new(order) } + + before do + # So that Payment#purchase! is called during processing + Spree::Config[:auto_capture] = true + + order.stub_chain(:line_items, :empty?).and_return(false) + order.stub :total => 100 + end + + it 'processes all payments' do + payment_1 = create(:payment, :amount => 50) + payment_2 = create(:payment, :amount => 50) + order.stub(:pending_payments).and_return([payment_1, payment_2]) + + order.process_payments! + updater.update_payment_state + order.payment_state.should == 'paid' + + payment_1.should be_completed + payment_2.should be_completed + end + + it 'does not go over total for order' do + payment_1 = create(:payment, :amount => 50) + payment_2 = create(:payment, :amount => 50) + payment_3 = create(:payment, :amount => 50) + order.stub(:pending_payments).and_return([payment_1, payment_2, payment_3]) + + order.process_payments! + updater.update_payment_state + order.payment_state.should == 'paid' + + payment_1.should be_completed + payment_2.should be_completed + payment_3.should be_checkout + end + + it "does not use failed payments" do + payment_1 = create(:payment, :amount => 50) + payment_2 = create(:payment, :amount => 50, :state => 'failed') + order.stub(:pending_payments).and_return([payment_1]) + + payment_2.should_not_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..a64a793b95 --- /dev/null +++ b/spec/models/spree/order/state_machine_spec.rb @@ -0,0 +1,183 @@ +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 + order.stub(:require_email) + end + + context "#next!" do + context "when current state is confirm" do + before do + order.state = "confirm" + order.run_callbacks(:create) + order.stub :payment_required? => true + order.stub :process_payments! => true + order.stub :has_available_shipment + end + + context "when payment processing succeeds" do + before { order.stub :process_payments! => true } + + it "should finalize order when transitioning to complete state" do + order.should_receive(:finalize!) + order.next! + end + + context "when credit card processing fails" do + before { order.stub :process_payments! => false } + + it "should not complete the order" do + order.next + order.state.should == "confirm" + end + end + + end + + context "when payment processing fails" do + before { order.stub :process_payments! => false } + + it "cannot transition to complete" do + order.next + order.state.should == "confirm" + end + end + + end + + context "when current state is address" do + before do + order.stub(:has_available_payment) + order.stub(: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 + Spree::TaxRate.should_receive(:adjust).twice + order.next! + end + end + + context "when current state is delivery" do + before do + order.state = "delivery" + order.stub :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 + order.stub :completed? => true + order.shipment_state = shipment_state + order.can_cancel?.should be_true + 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 + order.stub :completed? => true + order.shipment_state = shipment_state + order.can_cancel?.should be_false + end + end + + end + + context "#cancel" do + let!(:variant) { stub_model(Spree::Variant) } + let!(:inventory_units) { [stub_model(Spree::InventoryUnit, :variant => variant), + stub_model(Spree::InventoryUnit, :variant => variant) ]} + let!(:shipment) do + shipment = stub_model(Spree::Shipment) + shipment.stub :inventory_units => inventory_units + order.stub :shipments => [shipment] + shipment + end + + before do + order.stub :line_items => [stub_model(Spree::LineItem, :variant => variant, :quantity => 2)] + order.line_items.stub :find_by_variant_id => order.line_items.first + + order.stub :completed? => true + order.stub :allow_cancel? => true + end + + it "should send a cancel email" do + # Stub methods that cause side-effects in this test + shipment.stub(:cancel!) + order.stub :has_available_shipment + order.stub :restock_items! + mail_message = double "Mail::Message" + order_id = nil + Spree::OrderMailer.should_receive(:cancel_email) { |*args| + order_id = args[0] + mail_message + } + mail_message.should_receive :deliver + order.cancel! + order_id.should == order.id + end + + context "restocking inventory" do + before do + shipment.stub(:ensure_correct_adjustment) + shipment.stub(:update_order) + Spree::OrderMailer.stub(:cancel_email).and_return(mail_message = double) + mail_message.stub :deliver + + order.stub :has_available_shipment + end + end + + context "resets payment state" do + before do + # Stubs methods that cause unwanted side effects in this test + Spree::OrderMailer.stub(:cancel_email).and_return(mail_message = double) + mail_message.stub :deliver + order.stub :has_available_shipment + order.stub :restock_items! + shipment.stub(:cancel!) + end + + context "without shipped items" do + it "should set payment state to 'credit owed'" do + order.cancel! + order.payment_state.should == 'credit_owed' + end + end + + context "with shipped items" do + before do + order.stub :shipment_state => 'partial' + end + + it "should not alter the payment state" do + order.cancel! + order.payment_state.should be_nil + end + end + end + end + + # Another regression test for Spree #729 + context "#resume" do + before do + order.stub :email => "user@spreecommerce.com" + order.stub :state => "canceled" + order.stub :allow_resume? => true + + # Stubs method that cause unwanted side effects in this test + order.stub :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..045e0ee9e2 --- /dev/null +++ b/spec/models/spree/order/tax_spec.rb @@ -0,0 +1,115 @@ +require 'spec_helper' + +module Spree + describe Spree::Order do + let(:order) { stub_model(Spree::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 + order.tax_zone.should 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 + Spree::Zone.should_receive(:match).at_least(:once).with(ship_address) + Spree::Zone.should_not_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 + Spree::Zone.should_receive(:match).at_least(:once).with(bill_address) + Spree::Zone.should_not_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") + Spree::Zone.stub :default_tax => @default_zone + end + + context "when there is a matching zone" do + before { Spree::Zone.stub(:match => zone) } + + it "should return the matching zone" do + order.tax_zone.should == zone + end + end + + context "when there is no matching zone" do + before { Spree::Zone.stub(:match => nil) } + + it "should return the default tax zone" do + order.tax_zone.should == @default_zone + end + end + end + + context "when no default tax zone" do + before { Spree::Zone.stub :default_tax => nil } + + context "when there is a matching zone" do + before { Spree::Zone.stub(:match => zone) } + + it "should return the matching zone" do + order.tax_zone.should == zone + end + end + + context "when there is no matching zone" do + before { Spree::Zone.stub(:match => nil) } + + it "should return nil" do + order.tax_zone.should be_nil + end + end + end + end + + context "#exclude_tax?" do + before do + @order = create(:order) + @default_zone = create(:zone) + Spree::Zone.stub :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 + @order.stub :tax_zone => create(:zone, :name => "other_zone") + @order.exclude_tax?.should be_true + end + + it "should be false when tax_zone is the same as the default" do + @order.stub :tax_zone => @default_zone + @order.exclude_tax?.should be_false + end + end + + context "when prices do not include tax" do + before { Spree::Config.set(:prices_inc_tax => false) } + + it "should be false" do + @order.exclude_tax?.should be_false + 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..9b50d02986 --- /dev/null +++ b/spec/models/spree/order/updating_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe Spree::Order do + let(:order) { stub_model(Spree::Order) } + + context "#update!" do + let(:line_items) { [mock_model(Spree::LineItem, :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 + order.should_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..3ee1bb01f7 --- /dev/null +++ b/spec/models/spree/order_contents_spec.rb @@ -0,0 +1,92 @@ +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) + line_item.quantity.should == 1 + order.line_items.size.should == 1 + end + end + + it 'should add line item if one does not exist' do + line_item = subject.add(variant, 1) + line_item.quantity.should == 1 + order.line_items.size.should == 1 + end + + it 'should update line item if one exists' do + subject.add(variant, 1) + line_item = subject.add(variant, 1) + line_item.quantity.should == 2 + order.line_items.size.should == 1 + end + + it "should update order totals" do + order.item_total.to_f.should == 0.00 + order.total.to_f.should == 0.00 + + subject.add(variant, 1) + + order.item_total.to_f.should == 19.99 + order.total.to_f.should == 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) + + line_item.reload.quantity.should == 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) + + line_item.reload.quantity.should == 2 + end + + it 'should remove line_item if quantity matches line_item quantity' do + subject.add(variant, 1) + subject.remove(variant, 1) + + order.reload.find_line_item_by_variant(variant).should be_nil + end + + it "should update order totals" do + order.item_total.to_f.should == 0.00 + order.total.to_f.should == 0.00 + + subject.add(variant,2) + + order.item_total.to_f.should == 39.98 + order.total.to_f.should == 39.98 + + subject.remove(variant,1) + order.item_total.to_f.should == 19.99 + order.total.to_f.should == 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..5ca8b57f6e --- /dev/null +++ b/spec/models/spree/order_inventory_spec.rb @@ -0,0 +1,174 @@ +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) + units.map(&:variant_id).should == [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 + order.shipments.first.inventory_units_for(line_item.variant).size.should == 1 + line_item.reload.quantity.should == 2 + end + + it 'should increase the number of inventory units' do + subject.verify(line_item) + order.reload.shipments.first.inventory_units_for(line_item.variant).size.should == 2 + end + end + + context "#add_to_shipment" do + let(:shipment) { order.shipments.first } + let(:variant) { create :variant } + + context "order is not completed" do + before { order.stub completed?: false } + + it "doesn't unstock items" do + shipment.stock_location.should_not_receive(:unstock) + subject.send(:add_to_shipment, shipment, variant, 5).should == 5 + end + end + + it 'should create inventory_units in the necessary states' do + shipment.stock_location.should_receive(:fill_status).with(variant, 5).and_return([3, 2]) + + subject.send(:add_to_shipment, shipment, variant, 5).should == 5 + + units = shipment.inventory_units.group_by &:variant_id + units = units[variant.id].group_by &:state + units['backordered'].size.should == 2 + units['on_hand'].size.should == 3 + end + + it 'should create stock_movement' do + subject.send(:add_to_shipment, shipment, variant, 5).should == 5 + + stock_item = shipment.stock_location.stock_item(variant) + movement = stock_item.stock_movements.last + # movement.originator.should == shipment + movement.quantity.should == -5 + end + end + + context "#determine_target_shipment" do + let(:stock_location) { create :stock_location } + let(:variant) { line_item.variant } + + before do + order.shipments.create(:stock_location_id => stock_location.id) + shipped = order.shipments.create(:stock_location_id => order.shipments.first.stock_location.id) + shipped.update_column(:state, 'shipped') + end + + it 'should select first non-shipped shipment that already contains given variant' do + shipment = subject.send(:determine_target_shipment, variant) + shipment.shipped?.should be_false + shipment.inventory_units_for(variant).should_not be_empty + variant.stock_location_ids.include?(shipment.stock_location_id).should be_true + end + + context "when no shipments already contain this varint" do + it 'selects first non-shipped shipment that leaves from same stock_location' do + subject.send(:remove_from_shipment, order.shipments.first, variant, line_item.quantity) + + shipment = subject.send(:determine_target_shipment, variant) + shipment.reload + shipment.shipped?.should be_false + shipment.inventory_units_for(variant).should be_empty + variant.stock_location_ids.include?(shipment.stock_location_id).should be_true + end + 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 + order.shipments.first.inventory_units_for(line_item.variant).size.should == 3 + line_item.quantity.should == 2 + end + + it 'should decrease the number of inventory units' do + subject.verify(line_item) + order.reload.shipments.first.inventory_units_for(line_item.variant).size.should == 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 { order.stub completed?: false } + + it "doesn't restock items" do + shipment.stock_location.should_not_receive(:restock) + subject.send(:remove_from_shipment, shipment, variant, 1).should == 1 + end + end + + it 'should create stock_movement' do + subject.send(:remove_from_shipment, shipment, variant, 1).should == 1 + + stock_item = shipment.stock_location.stock_item(variant) + movement = stock_item.stock_movements.last + # movement.originator.should == shipment + movement.quantity.should == 1 + end + + it 'should destroy backordered units first' do + shipment.stub(:inventory_units_for => [ mock_model(Spree::InventoryUnit, :variant_id => variant.id, :state => 'backordered'), + mock_model(Spree::InventoryUnit, :variant_id => variant.id, :state => 'on_hand'), + mock_model(Spree::InventoryUnit, :variant_id => variant.id, :state => 'backordered') ]) + + shipment.inventory_units_for[0].should_receive(:destroy) + shipment.inventory_units_for[1].should_not_receive(:destroy) + shipment.inventory_units_for[2].should_receive(:destroy) + + subject.send(:remove_from_shipment, shipment, variant, 2).should == 2 + end + + it 'should destroy unshipped units first' do + shipment.stub(:inventory_units_for => [ mock_model(Spree::InventoryUnit, :variant_id => variant.id, :state => 'shipped'), + mock_model(Spree::InventoryUnit, :variant_id => variant.id, :state => 'on_hand') ] ) + + shipment.inventory_units_for[0].should_not_receive(:destroy) + shipment.inventory_units_for[1].should_receive(:destroy) + + subject.send(:remove_from_shipment, shipment, variant, 1).should == 1 + end + + it 'only attempts to destroy as many units as are eligible, and return amount destroyed' do + shipment.stub(:inventory_units_for => [ mock_model(Spree::InventoryUnit, :variant_id => variant.id, :state => 'shipped'), + mock_model(Spree::InventoryUnit, :variant_id => variant.id, :state => 'on_hand') ] ) + + shipment.inventory_units_for[0].should_not_receive(:destroy) + shipment.inventory_units_for[1].should_receive(:destroy) + + subject.send(:remove_from_shipment, shipment, variant, 1).should == 1 + end + + it 'should destroy self if not inventory units remain' do + shipment.inventory_units.stub(:count => 0) + shipment.should_receive(:destroy) + + subject.send(:remove_from_shipment, shipment, variant, 1).should == 1 + end + end + end +end diff --git a/spec/models/spree/order_spec.rb b/spec/models/spree/order_spec.rb index 5c583a5252..566e579e88 100644 --- a/spec/models/spree/order_spec.rb +++ b/spec/models/spree/order_spec.rb @@ -3,6 +3,617 @@ require 'spec_helper' describe Spree::Order do include OpenFoodNetwork::EmailHelper + let(:user) { stub_model(Spree::LegacyUser, :email => "spree@example.com") } + let(:order) { stub_model(Spree::Order, :user => user) } + + before do + Spree::LegacyUser.stub(:current => mock_model(Spree::LegacyUser, :id => 123)) + end + + context "#products" do + before :each do + @variant1 = mock_model(Spree::Variant, :product => "product1") + @variant2 = mock_model(Spree::Variant, :product => "product2") + @line_items = [mock_model(Spree::LineItem, :product => "product1", :variant => @variant1, :variant_id => @variant1.id, :quantity => 1), + mock_model(Spree::LineItem, :product => "product2", :variant => @variant2, :variant_id => @variant2.id, :quantity => 2)] + order.stub(:line_items => @line_items) + end + + it "should return ordered products" do + order.products.should == ['product1', 'product2'] + end + + it "contains?" do + order.contains?(@variant1).should be_true + end + + it "gets the quantity of a given variant" do + order.quantity_of(@variant1).should == 1 + + @variant3 = mock_model(Spree::Variant, :product => "product3") + order.quantity_of(@variant3).should == 0 + end + + it "can find a line item matching a given variant" do + order.find_line_item_by_variant(@variant1).should_not be_nil + order.find_line_item_by_variant(mock_model(Spree::Variant)).should be_nil + end + end + + context "#generate_order_number" do + it "should generate a random string" do + order.generate_order_number.is_a?(String).should be_true + (order.generate_order_number.to_s.length > 0).should be_true + end + end + + context "#associate_user!" do + it "should associate a user with a persisted order" do + order = FactoryGirl.create(:order_with_line_items, created_by: nil) + user = FactoryGirl.create(:user) + + order.user = nil + order.email = nil + order.associate_user!(user) + order.user.should == user + order.email.should == user.email + order.created_by.should == user + + # verify that the changes we made were persisted + order.reload + order.user.should == user + order.email.should == user.email + order.created_by.should == user + end + + it "should not overwrite the created_by if it already is set" do + creator = create(:user) + order = FactoryGirl.create(:order_with_line_items, created_by: creator) + user = FactoryGirl.create(:user) + + order.user = nil + order.email = nil + order.associate_user!(user) + order.user.should == user + order.email.should == user.email + order.created_by.should == creator + + # verify that the changes we made were persisted + order.reload + order.user.should == user + order.email.should == user.email + order.created_by.should == 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 + order.number.should_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 + order.stub(:complete? => true) + order.can_ship?.should be_true + end + + it "should be true for order in the 'resumed' state" do + order.stub(:resumed? => true) + order.can_ship?.should be_true + end + + it "should be true for an order in the 'awaiting return' state" do + order.stub(:awaiting_return? => true) + order.can_ship?.should be_true + end + + it "should be true for an order in the 'returned' state" do + order.stub(:returned? => true) + order.can_ship?.should be_true + end + + it "should be false if the order is neither in the 'complete' nor 'resumed' state" do + order.stub(:resumed? => false, :complete? => false) + order.can_ship?.should be_false + end + end + + context "checking if order is paid" do + context "payment_state is paid" do + before { order.stub payment_state: 'paid' } + it { expect(order).to be_paid } + end + + context "payment_state is credit_owned" do + before { order.stub 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 + order.should_receive(:touch).with(:completed_at) + order.finalize! + end + + it "should sell inventory units" do + order.shipments.each do |shipment| + shipment.should_receive(:update!) + shipment.should_receive(:finalize!) + end + order.finalize! + end + + it "should decrease the stock for each variant in the shipment" do + order.shipments.each do |shipment| + shipment.stock_location.should_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 + + order.stub(:paid? => true, :complete? => true) + order.finalize! + order.reload # reload so we're sure the changes are persisted + order.shipment_state.should == 'ready' + end + + after { Spree::Config.set :track_inventory_levels => true } + it "should not sell inventory units if track_inventory_levels is false" do + Spree::Config.set :track_inventory_levels => false + Spree::InventoryUnit.should_not_receive(:sell_units) + order.finalize! + end + + it "should send an order confirmation email" do + mail_message = double "Mail::Message" + Spree::OrderMailer.should_receive(:confirm_email).with(order.id).and_return mail_message + mail_message.should_receive :deliver + order.finalize! + end + + it "should continue even if confirmation email delivery fails" do + Spree::OrderMailer.should_receive(:confirm_email).with(order.id).and_raise 'send failed!' + order.finalize! + 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 + order.stub :has_available_shipment + Spree::OrderMailer.stub_chain :confirm_email, :deliver + adjustments = double + order.stub :adjustments => adjustments + expect(adjustments).to receive(:update_all).with(state: 'closed') + order.finalize! + end + + it "should log state event" do + order.state_changes.should_receive(:create).exactly(3).times #order, shipment & payment state changes + order.finalize! + end + + it 'calls updater#before_save' do + order.updater.should_receive(:before_save_hook) + order.finalize! + end + end + + context "#process_payments!" do + let(:payment) { stub_model(Spree::Payment) } + before { order.stub :pending_payments => [payment], :total => 10 } + + it "should process the payments" do + payment.should_receive(:process!) + order.process_payments!.should be_true + end + + it "should return false if no pending_payments available" do + order.stub :pending_payments => [] + order.process_payments!.should be_false + end + + context "when a payment raises a GatewayError" do + before { payment.should_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 + order.process_payments!.should be_true + end + + it "should return false when not configured to allow checkout on gateway failures" do + Spree::Config.set :allow_checkout_on_gateway_error => false + order.process_payments!.should be_false + 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 + order.outstanding_balance.should == 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 + order.outstanding_balance.should 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 + order.outstanding_balance?.should be_true + end + it "should be true when total less than payment_total" do + order.total = 8.25 + order.payment_total = 10.44 + order.outstanding_balance?.should be_true + end + it "should be false when total equals payment_total" do + order.total = 10.10 + order.payment_total = 10.10 + order.outstanding_balance?.should be_false + end + end + + context "#completed?" do + it "should indicate if order is completed" do + order.completed_at = nil + order.completed?.should be_false + + order.completed_at = Time.now + order.completed?.should be_true + end + end + + it 'is backordered if one of the shipments is backordered' do + order.stub(:shipments => [mock_model(Spree::Shipment, :backordered? => false), + mock_model(Spree::Shipment, :backordered? => true)]) + order.should be_backordered + end + + context "#allow_checkout?" do + it "should be true if there are line_items in the order" do + order.stub_chain(:line_items, :count => 1) + order.checkout_allowed?.should be_true + end + it "should be false if there are no line_items in the order" do + order.stub_chain(:line_items, :count => 0) + order.checkout_allowed?.should be_false + 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 + @order.item_count.should == 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 + @order.amount.should == 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.now + order.can_cancel?.should be_false + end + + it "should be true for completed order with no shipment" do + order.state = 'complete' + order.shipment_state = nil + order.completed_at = Time.now + order.can_cancel?.should be_true + end + end + + context "insufficient_stock_lines" do + let(:line_item) { mock_model Spree::LineItem, :insufficient_stock? => true } + + before { order.stub(:line_items => [line_item]) } + + it "should return line_item that has insufficient stock on hand" do + order.insufficient_stock_lines.size.should == 1 + order.insufficient_stock_lines.include?(line_item).should be_true + end + + end + + context "empty!" do + it "should clear out all line items and adjustments" do + order = stub_model(Spree::Order) + order.stub(:line_items => line_items = []) + order.stub(:adjustments => adjustments = []) + order.line_items.should_receive(:destroy_all) + order.adjustments.should_receive(:destroy_all) + + order.empty! + end + end + + context "#display_outstanding_balance" do + it "returns the value as a spree money" do + order.stub(:outstanding_balance) { 10.55 } + order.display_outstanding_balance.should == Spree::Money.new(10.55) + end + end + + context "#display_item_total" do + it "returns the value as a spree money" do + order.stub(:item_total) { 10.55 } + order.display_item_total.should == 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 + order.display_adjustment_total.should == Spree::Money.new(10.55) + end + end + + context "#display_total" do + it "returns the value as a spree money" do + order.total = 10.55 + order.display_total.should == 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 + order.currency.should == "ABC" + end + end + + context "when object currency is nil" do + before { order.currency = nil } + + it "returns the globally configured currency" do + order.currency.should == "USD" + end + end + end + + # Regression tests for Spree #2179 + context "#merge!" do + let(:variant) { create(:variant) } + let(:order_1) { Spree::Order.create } + let(:order_2) { Spree::Order.create } + + it "destroys the other order" do + order_1.merge!(order_2) + lambda { order_2.reload }.should raise_error(ActiveRecord::RecordNotFound) + end + + context "merging together two orders with line items for the same variant" do + before do + order_1.contents.add(variant, 1) + order_2.contents.add(variant, 1) + end + + specify do + order_1.merge!(order_2) + order_1.line_items.count.should == 1 + + line_item = order_1.line_items.first + line_item.quantity.should == 2 + line_item.variant_id.should == variant.id + end + end + + context "merging together two orders with different line items" do + let(:variant_2) { create(:variant) } + + before do + order_1.contents.add(variant, 1) + order_2.contents.add(variant_2, 1) + end + + specify do + order_1.merge!(order_2) + line_items = order_1.line_items + line_items.count.should == 2 + + # No guarantee on ordering of line items, so we do this: + line_items.pluck(:quantity).should =~ [1, 1] + line_items.pluck(:variant_id).should =~ [variant.id, variant_2.id] + end + end + end + + context "#confirmation_required?" do + it "does not bomb out when an order has an unpersisted payment" do + order = Spree::Order.new + order.payments.build + assert !order.confirmation_required? + 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 + persisted_order.stub(: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 + persisted_order.stub(payment_required?: true) + persisted_order.next! + persisted_order.state.should == "payment" + end + end + + context "promotion adjustments" do + let(:originator) { double("Originator", id: 1) } + let(:adjustment) { double("Adjustment", originator: originator) } + + before { order.stub_chain(:adjustments, :includes, :promotion, reload: [adjustment]) } + + context "order has an adjustment from given promo action" do + it { expect(order.promotion_credit_exists? originator).to be_true } + end + + context "order has no adjustment from given promo action" do + before { originator.stub(id: 12) } + it { expect(order.promotion_credit_exists? originator).to be_true } + end + end + + context "payment required?" do + let(:order) { Spree::Order.new } + + context "total is zero" do + it { order.payment_required?.should be_false } + end + + context "total > zero" do + before { order.stub(total: 1) } + it { order.payment_required?.should be_true } + 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) + order.should_receive(:add_awesome_sauce) + order.update! + end + + it "calls hook during finalize" do + order = create(:order) + order.should_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 + subject.should == 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 + subject.should == order.bill_address + end + end + end + + context '#updater' do + class FakeOrderUpdaterDecorator + attr_reader :decorated_object + + def initialize(decorated_object) + @decorated_object = decorated_object + end + end + + before do + Spree::Config.stub(:order_updater_decorator) { FakeOrderUpdaterDecorator } + end + + it 'returns an order_updater_decorator class' do + order.updater.class.should == FakeOrderUpdaterDecorator + end + + it 'decorates a Spree::OrderUpdater' do + order.updater.decorated_object.class.should == Spree::OrderUpdater + end + end + describe "email validation" do let(:order) { build(:order) } diff --git a/spec/models/spree/return_authorization_spec.rb b/spec/models/spree/return_authorization_spec.rb new file mode 100644 index 0000000000..f76854ec8b --- /dev/null +++ b/spec/models/spree/return_authorization_spec.rb @@ -0,0 +1,138 @@ +require 'spec_helper' + +describe Spree::ReturnAuthorization do + let(:stock_location) {Spree::StockLocation.create(:name => "test")} + let(:order) { FactoryGirl.create(:shipped_order)} + let(:variant) { order.shipments.first.inventory_units.first.variant } + let(:return_authorization) { Spree::ReturnAuthorization.new(:order => order, :stock_location_id => stock_location.id) } + + context "save" do + it "should be invalid when order has no inventory units" do + order.shipments.destroy_all + return_authorization.save + return_authorization.errors[:order].should == ["has no shipped units"] + end + + it "should generate RMA number" do + return_authorization.should_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) + return_authorization.inventory_units.size.should == 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 + order.should_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 + return_authorization.stub(:inventory_units => [1,2,3]) + return_authorization.can_receive?.should be_true + end + + it "should not allow_receive with no inventory units" do + return_authorization.stub(:inventory_units => []) + return_authorization.can_receive?.should be_false + end + end + + context "receive!" do + let(:inventory_unit) { order.shipments.first.inventory_units.first } + + before do + return_authorization.stub(:inventory_units => [inventory_unit], :amount => -20) + Spree::Adjustment.stub(:create) + order.stub(:update!) + end + + it "should mark all inventory units are returned" do + inventory_unit.should_receive(:return!) + return_authorization.receive! + end + + it "should add credit for specified amount" do + return_authorization.amount = 20 + mock_adjustment = double + mock_adjustment.should_receive(:source=).with(return_authorization) + mock_adjustment.should_receive(:adjustable=).with(order) + mock_adjustment.should_receive(:save) + Spree::Adjustment.should_receive(:new).with(:amount => -20, :label => Spree.t(:rma_credit)).and_return(mock_adjustment) + return_authorization.receive! + end + + it "should update order state" do + order.should_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 + return_authorization.amount.should == 10 + end + end + + context "after_save" do + it "should run correct callbacks" do + return_authorization.should_receive(:force_positive_amount) + return_authorization.run_callbacks(:save) + end + end + + context "currency" do + before { order.stub(:currency) { "ABC" } } + it "returns the order currency" do + return_authorization.currency.should == "ABC" + end + end + + context "display_amount" do + it "returns a Spree::Money" do + return_authorization.amount = 21.22 + return_authorization.display_amount.should == Spree::Money.new(21.22) + end + end + + context "returnable_inventory" do + pending "should return inventory from shipped shipments" do + return_authorization.returnable_inventory.should == [inventory_unit] + end + + pending "should not return inventory from unshipped shipments" do + return_authorization.returnable_inventory.should == [] + end + end +end