Merge decorators with original files from spree_core

EPIC COMMIT ALERT :-)
This commit is contained in:
Luis Ramos
2020-08-08 15:26:56 +01:00
parent 47d2f698ef
commit 6900f7a46f
4 changed files with 572 additions and 650 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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