diff --git a/app/models/spree/line_item.rb b/app/models/spree/line_item.rb index 2e160ba018..c84a3727db 100644 --- a/app/models/spree/line_item.rb +++ b/app/models/spree/line_item.rb @@ -1,13 +1,21 @@ +require 'open_food_network/scope_variant_to_hub' +require 'variant_units/variant_and_line_item_naming' + module Spree class LineItem < ActiveRecord::Base - before_validation :adjust_quantity - belongs_to :order, class_name: "Spree::Order" + 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 @@ -21,12 +29,73 @@ module Spree 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 if variant self.price = variant.price if price.nil? @@ -61,8 +130,15 @@ module Spree 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? - Stock::Quantifier.new(variant_id).can_supply? quantity + return true if skip_stock_check + return true if quantity <= 0 + + scoper.scope(variant) + variant.can_supply?(quantity) end def insufficient_stock? @@ -78,14 +154,74 @@ module Spree variant.product end - # Remove variant default_scope `deleted_at: nil` + # 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 { 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 if changed? + scoper.scope(variant) Spree::OrderInventory.new(self.order).verify(self, target_shipment) end end @@ -97,6 +233,26 @@ module Spree order.update! end 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 index b1056f3479..bfafea0ec4 100644 --- a/app/models/spree/order.rb +++ b/app/models/spree/order.rb @@ -1,8 +1,17 @@ require 'spree/core/validators/email' 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 @@ -17,6 +26,17 @@ module Spree remove_transition from: :delivery, to: :confirm end + # 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 + token_resource attr_reader :coupon_code @@ -39,7 +59,10 @@ module Spree 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 :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 @@ -48,16 +71,31 @@ module Spree 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 @@ -68,11 +106,57 @@ module Spree validate :has_available_shipment validate :has_available_payment + # 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 + 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 @@ -152,9 +236,16 @@ module Spree 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 + total.to_f > 0.0 && !skip_payment_for_subscription? end # If true, causes the confirmation step to happen during the checkout process @@ -201,9 +292,7 @@ module Spree end def updater - @updater ||= Spree::Config.order_updater_decorator.new( - Spree::OrderUpdater.new(self) - ) + @updater ||= OrderManagement::Order::Updater.new(self) end def update! @@ -239,10 +328,68 @@ module Spree return_authorizations.any? { |return_authorization| return_authorization.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 + + # 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 + + 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 + # Associates the specified user with the order. def associate_user!(user) self.user = user @@ -351,11 +498,8 @@ module Spree 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") + if subscription.blank? + Delayed::Job.enqueue ConfirmOrderJob.new(id) end end @@ -368,8 +512,10 @@ module Spree @available_payment_methods ||= PaymentMethod.available(:front_end) 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(&:checkout?) + (payments.select(&:pending?) + payments.select(&:processing?) + payments.select(&:checkout?)).uniq end # processes any pending payments and must return a boolean as it's @@ -444,6 +590,8 @@ module Spree def empty! line_items.destroy_all adjustments.destroy_all + payments.clear + shipments.destroy_all end def clear_adjustments! @@ -490,11 +638,38 @@ module Spree %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 = Spree::Stock::Coordinator.new(self).packages + packages = OrderManagement::Stock::Coordinator.new(self).packages packages.each do |package| shipments << package.to_shipment end @@ -518,6 +693,159 @@ module Spree shipments.map &:refresh_rates end + 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 disallow_guest_order + if using_guest_checkout? && registered_email? + errors.add(:base, I18n.t('devise.failure.already_registered')) + end + 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).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 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 @@ -571,5 +899,74 @@ module Spree 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 + 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 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