Merge pull request #12256 from feruzoripov/services/group

Group `Order && OrderCycle` related services
This commit is contained in:
Gaetan Craig-Riou
2024-03-18 11:07:22 +11:00
committed by GitHub
141 changed files with 1699 additions and 1685 deletions

View File

@@ -141,7 +141,7 @@ Metrics/AbcSize:
- 'lib/open_food_network/order_cycle_permissions.rb'
- 'lib/spree/core/controller_helpers/order.rb'
- 'lib/tasks/enterprises.rake'
- 'spec/services/order_checkout_restart_spec.rb'
- 'spec/services/orders/checkout_restart_service_spec.rb'
# Offense count: 9
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode.
@@ -200,8 +200,8 @@ Metrics/ClassLength:
- 'app/serializers/api/cached_enterprise_serializer.rb'
- 'app/serializers/api/enterprise_shopfront_serializer.rb'
- 'app/services/cart_service.rb'
- 'app/services/order_cycle_form.rb'
- 'app/services/order_syncer.rb'
- 'app/services/orders/sync_service.rb'
- 'app/services/order_cycles/form_service.rb'
- 'engines/order_management/app/services/order_management/order/updater.rb'
- 'lib/open_food_network/enterprise_fee_calculator.rb'
- 'lib/open_food_network/order_cycle_form_applicator.rb'
@@ -397,7 +397,7 @@ Rails/FindEach:
Exclude:
- 'app/controllers/admin/order_cycles_controller.rb'
- 'app/jobs/subscription_confirm_job.rb'
- 'app/services/orders_bulk_cancel_service.rb'
- 'app/services/orders/bulk_cancel_service.rb'
- 'app/services/products_renderer.rb'
- 'lib/tasks/data.rake'
- 'lib/tasks/subscriptions/debug.rake'
@@ -618,8 +618,8 @@ Rails/TimeZone:
Exclude:
- 'app/models/spree/gateway/pay_pal_express.rb'
- 'spec/controllers/spree/credit_cards_controller_spec.rb'
- 'spec/services/customer_order_cancellation_spec.rb'
- 'spec/services/order_cycle_webhook_service_spec.rb'
- 'spec/services/orders/customer_cancellation_service_spec.rb'
- 'spec/services/order_cycles/webhook_service_spec.rb'
# Offense count: 1
# Configuration parameters: TransactionMethods.

View File

@@ -45,7 +45,8 @@ module Admin
end
def create
@order_cycle_form = OrderCycleForm.new(@order_cycle, order_cycle_params, spree_current_user)
@order_cycle_form = OrderCycles::FormService.new(@order_cycle, order_cycle_params,
spree_current_user)
if @order_cycle_form.save
flash[:success] = t('.success')
@@ -61,7 +62,8 @@ module Admin
end
def update
@order_cycle_form = OrderCycleForm.new(@order_cycle, order_cycle_params, spree_current_user)
@order_cycle_form = OrderCycles::FormService.new(@order_cycle, order_cycle_params,
spree_current_user)
if @order_cycle_form.save
update_nil_subscription_line_items_price_estimate(@order_cycle)

View File

@@ -101,7 +101,8 @@ module Api
end
def distributed_products
OrderCycleDistributedProducts.new(distributor, order_cycle, customer).products_relation
OrderCycles::DistributedProductsService.new(distributor, order_cycle,
customer).products_relation
end
end
end

View File

@@ -47,7 +47,7 @@ module Api
def capture
authorize! :admin, order
payment_capture = OrderCaptureService.new(order)
payment_capture = Orders::CaptureService.new(order)
if payment_capture.call
render json: order.reload, serializer: Api::Admin::OrderSerializer, status: :ok

View File

@@ -21,7 +21,7 @@ module Api
@shipment.refresh_rates
@shipment.save!
OrderWorkflow.new(@order).advance_to_payment if @order.line_items.any?
Orders::WorkflowService.new(@order).advance_to_payment if @order.line_items.any?
@order.recreate_all_fees!

View File

@@ -128,7 +128,7 @@ class CheckoutController < BaseController
def advance_order_state
return if @order.complete?
OrderWorkflow.new(@order).advance_checkout(raw_params.slice(:shipping_method_id))
Orders::WorkflowService.new(@order).advance_checkout(raw_params.slice(:shipping_method_id))
end
def order_params

View File

@@ -65,7 +65,7 @@ module CheckoutCallbacks
def valid_order_line_items?
@order.insufficient_stock_lines.empty? &&
OrderCycleDistributedVariants.new(@order.order_cycle, @order.distributor).
OrderCycles::DistributedVariantsService.new(@order.order_cycle, @order.distributor).
distributes_order_variants?(@order)
end

View File

@@ -64,7 +64,7 @@ module OrderCompletion
return redirect_to order_failed_route(step: 'payment')
end
if OrderWorkflow.new(@order).next && @order.complete?
if Orders::WorkflowService.new(@order).next && @order.complete?
processing_succeeded
redirect_to order_completion_route
else

View File

@@ -6,7 +6,7 @@ module OrderStockCheck
def valid_order_line_items?
@order.insufficient_stock_lines.empty? &&
OrderCycleDistributedVariants.new(@order.order_cycle, @order.distributor).
OrderCycles::DistributedVariantsService.new(@order.order_cycle, @order.distributor).
distributes_order_variants?(@order)
end

View File

@@ -70,7 +70,7 @@ class EnterprisesController < BaseController
order = current_order(true)
# reset_distributor must be called before any call to current_customer or current_distributor
order_cart_reset = OrderCartReset.new(order, params[:id])
order_cart_reset = Orders::CartResetService.new(order, params[:id])
order_cart_reset.reset_distributor
order_cart_reset.reset_other!(spree_current_user, current_customer)
rescue ActiveRecord::RecordNotFound

View File

@@ -79,7 +79,7 @@ module PaymentGateways
end
def last_payment
@last_payment ||= OrderPaymentFinder.new(@order).last_payment
@last_payment ||= Orders::FindPaymentService.new(@order).last_payment
end
def cancel_incomplete_payments

View File

@@ -26,7 +26,7 @@ module Spree
def warn_invalid_order_cycles
return if flash[:notice].present?
warning = OrderCycleWarning.new(spree_current_user).call
warning = OrderCycles::WarningService.new(spree_current_user).call
flash[:notice] = warning if warning.present?
end

View File

@@ -20,7 +20,7 @@ module Spree
def generate
@order = Order.find_by(number: params[:order_id])
authorize! :invoice, @order
OrderInvoiceGenerator.new(@order).generate_or_update_latest_invoice
::Orders::GenerateInvoiceService.new(@order).generate_or_update_latest_invoice
redirect_back(fallback_location: spree.admin_dashboard_path)
end

View File

@@ -24,7 +24,7 @@ module Spree
end
refresh_shipment_rates
OrderWorkflow.new(@order).advance_to_payment
::Orders::WorkflowService.new(@order).advance_to_payment
flash[:success] = Spree.t('customer_details_updated')
redirect_to spree.admin_order_customer_path(@order)

View File

@@ -50,7 +50,7 @@ module Spree
return redirect_to spree.edit_admin_order_path(@order)
end
OrderWorkflow.new(@order).advance_to_payment
::Orders::WorkflowService.new(@order).advance_to_payment
if @order.complete?
redirect_to spree.edit_admin_order_path(@order)
@@ -104,7 +104,7 @@ module Spree
@order = if params[:invoice_id].present?
@order.invoices.find(params[:invoice_id]).presenter
else
OrderInvoiceGenerator.new(@order).generate_or_update_latest_invoice
::Orders::GenerateInvoiceService.new(@order).generate_or_update_latest_invoice
@order.invoices.first.presenter
end
end

View File

@@ -33,7 +33,7 @@ module Spree
return
end
OrderWorkflow.new(@order).complete! unless @order.completed?
::Orders::WorkflowService.new(@order).complete! unless @order.completed?
authorize_stripe_sca_payment
@payment.process_offline!

View File

@@ -42,7 +42,7 @@ module Spree
# Patching to redirect to shop if order is empty
def edit
@insufficient_stock_lines = @order.insufficient_stock_lines
@unavailable_order_variants = OrderCycleDistributedVariants.
@unavailable_order_variants = OrderCycles::DistributedVariantsService.
new(current_order_cycle, current_distributor).unavailable_order_variants(@order)
if @order.line_items.empty?
@@ -102,7 +102,7 @@ module Spree
@order = Spree::Order.find_by!(number: params[:id])
authorize! :cancel, @order
if CustomerOrderCancellation.new(@order).call
if Orders::CustomerCancellationService.new(@order).call
flash[:success] = I18n.t(:orders_your_order_has_been_cancelled)
else
flash[:error] = I18n.t(:orders_could_not_cancel)

View File

@@ -59,7 +59,7 @@ module CheckoutHelper
end
def display_checkout_taxes_hash(order)
totals = OrderTaxAdjustmentsFetcher.new(order).totals
totals = Orders::FetchTaxAdjustmentsService.new(order).totals
totals.map do |tax_rate, tax_amount|
{

View File

@@ -12,11 +12,11 @@ module EnterprisesHelper
end
def available_shipping_methods
OrderAvailableShippingMethods.new(current_order, current_customer).to_a
Orders::AvailableShippingMethodsService.new(current_order, current_customer).to_a
end
def available_payment_methods
OrderAvailablePaymentMethods.new(current_order, current_customer).to_a
Orders::AvailablePaymentMethodsService.new(current_order, current_customer).to_a
end
def managed_enterprises

View File

@@ -2,7 +2,7 @@
module OrderHelper
def last_payment_method(order)
OrderPaymentFinder.new(order).last_payment&.payment_method
Orders::FindPaymentService.new(order).last_payment&.payment_method
end
def outstanding_balance_label(order)
@@ -16,6 +16,6 @@ module OrderHelper
end
def order_comparator(order)
OrderInvoiceComparator.new(order)
Orders::CompareInvoiceService.new(order)
end
end

View File

@@ -33,7 +33,7 @@ class BulkInvoiceJob < ApplicationJob
def generate_invoice(order)
renderer_data = if OpenFoodNetwork::FeatureToggle.enabled?(:invoices, current_user)
OrderInvoiceGenerator.new(order).generate_or_update_latest_invoice
Orders::GenerateInvoiceService.new(order).generate_or_update_latest_invoice
order.invoices.first.presenter
else
order

View File

@@ -5,7 +5,7 @@ class OrderCycleOpenedJob < ApplicationJob
def perform
ActiveRecord::Base.transaction do
recently_opened_order_cycles.find_each do |order_cycle|
OrderCycleWebhookService.create_webhook_job(order_cycle, 'order_cycle.opened')
OrderCycles::WebhookService.create_webhook_job(order_cycle, 'order_cycle.opened')
end
mark_as_opened(recently_opened_order_cycles)
end

View File

@@ -52,7 +52,8 @@ module Spree
find_user(options[:current_user_id])
end
renderer_data = if OpenFoodNetwork::FeatureToggle.enabled?(:invoices, current_user)
OrderInvoiceGenerator.new(@order).generate_or_update_latest_invoice
::Orders::GenerateInvoiceService
.new(@order).generate_or_update_latest_invoice
@order.invoices.first.presenter
else
@order

View File

@@ -15,7 +15,7 @@ module OrderValidations
# Check that line_items in the current order are available from a newly selected distribution
def products_available_from_new_distribution
return if OrderCycleDistributedVariants.new(order_cycle, distributor)
return if OrderCycles::DistributedVariantsService.new(order_cycle, distributor)
.distributes_order_variants?(self)
errors.add(:base, I18n.t(:spree_order_availability_error))

View File

@@ -175,7 +175,7 @@ class OrderCycle < ApplicationRecord
end
def clone!
OrderCycleClone.new(self).create
OrderCycles::CloneService.new(self).create
end
def variants

View File

@@ -58,7 +58,7 @@ class ProxyOrder < ApplicationRecord
def initialise_order!
return order if order.present?
factory = OrderFactory.new(order_attrs, skip_stock_check: true)
factory = Orders::FactoryService.new(order_attrs, skip_stock_check: true)
self.order = factory.create
save!
order

View File

@@ -652,7 +652,7 @@ module Spree
end
def fee_handler
@fee_handler ||= OrderFeesHandler.new(self)
@fee_handler ||= Orders::HandleFeesService.new(self)
end
def clear_legacy_taxes!
@@ -701,7 +701,7 @@ module Spree
end
def adjustments_fetcher
@adjustments_fetcher ||= OrderAdjustmentsFetcher.new(self)
@adjustments_fetcher ||= Orders::FetchAdjustmentsService.new(self)
end
def skip_payment_for_subscription?

View File

@@ -48,7 +48,7 @@ module Spree
end
def capture_and_complete_order!
OrderWorkflow.new(order).complete!
Orders::WorkflowService.new(order).complete!
capture!
end

View File

@@ -5,7 +5,7 @@ module Admin
before_reflex :authorize_order, only: [:capture, :ship]
def capture
payment_capture = OrderCaptureService.new(@order)
payment_capture = Orders::CaptureService.new(@order)
if payment_capture.call
cable_ready.replace(selector: dom_id(@order),
@@ -55,7 +55,7 @@ module Admin
end
def cancel_orders(params)
cancelled_orders = OrdersBulkCancelService.new(params, current_user).call
cancelled_orders = Orders::BulkCancelService.new(params, current_user).call
cable_ready.dispatch_event(name: "modal:close")

View File

@@ -46,6 +46,7 @@ class CapQuantity
end
def available_variants_for
OrderCycleDistributedVariants.new(order.order_cycle, order.distributor).available_variants
OrderCycles::DistributedVariantsService.new(order.order_cycle,
order.distributor).available_variants
end
end

View File

@@ -154,7 +154,7 @@ class CartService
end
def check_variant_available_under_distribution(variant)
return true if OrderCycleDistributedVariants.new(@order_cycle, @distributor)
return true if OrderCycles::DistributedVariantsService.new(@order_cycle, @distributor)
.available_variants.include? variant
errors.add(:base, I18n.t(:spree_order_populator_availability_error))

View File

@@ -14,7 +14,7 @@ module Checkout
def failure
@order.updater.shipping_address_from_distributor
OrderCheckoutRestart.new(@order).call
Orders::CheckoutRestartService.new(@order).call
end
private

View File

@@ -1,67 +0,0 @@
# frozen_string_literal: true
# Creates an order cycle for the provided enterprise and selecting all the
# variants specified for both incoming and outgoing exchanges
class CreateOrderCycle
# Constructor
#
# @param enterprise [Enterprise]
# @param variants [Array<Spree::Variant>]
def initialize(enterprise, variants)
@enterprise = enterprise
@variants = variants
end
# Creates the order cycle
def call
incoming_exchange.order_cycle = order_cycle
incoming_exchange.variants << variants
outgoing_exchange.order_cycle = order_cycle
outgoing_exchange.variants << variants
order_cycle.exchanges << incoming_exchange
order_cycle.exchanges << outgoing_exchange
order_cycle.save!
end
private
attr_reader :enterprise, :variants
# Builds an order cycle for the next month, starting now
#
# @return [OrderCycle]
def order_cycle
@order_cycle ||= OrderCycle.new(
coordinator_id: enterprise.id,
name: 'Monthly order cycle',
orders_open_at: Time.zone.now,
orders_close_at: 1.month.from_now
)
end
# Builds an exchange with the enterprise both as sender and receiver
#
# @return [Exchange]
def incoming_exchange
@incoming_exchange ||= Exchange.new(
sender_id: enterprise.id,
receiver_id: enterprise.id,
incoming: true
)
end
# Builds an exchange with the enterprise both as sender and receiver
#
# @return [Exchange]
def outgoing_exchange
@outgoing_exchange ||= Exchange.new(
sender_id: enterprise.id,
receiver_id: enterprise.id,
pickup_time: '8 am',
incoming: false
)
end
end

View File

@@ -1,17 +0,0 @@
# frozen_string_literal: true
class CustomerOrderCancellation
def initialize(order)
@order = order
end
def call
return unless order.cancel
Spree::OrderMailer.cancel_email_for_shop(order).deliver_later
end
private
attr_reader :order
end

View File

@@ -1,79 +0,0 @@
# frozen_string_literal: true
# This class allows orders with eager-loaded adjustment objects to calculate various adjustment
# types without triggering additional queries.
#
# For example; `order.adjustments.shipping.sum(:amount)` would normally trigger a new query
# regardless of whether or not adjustments have been preloaded, as `#shipping` is an adjustment
# scope, eg; `scope :shipping, where(originator_type: 'Spree::ShippingMethod')`.
#
# Here the adjustment scopes are moved to a shared module, and `adjustments.loaded?` is used to
# check if the objects have already been fetched and initialized. If they have, `order.adjustments`
# will be an Array, and we can select the required objects without hitting the database. If not, it
# will fetch the adjustments via their scopes as normal.
class OrderAdjustmentsFetcher
include AdjustmentScopes
def initialize(order)
@order = order
end
def admin_and_handling_total
admin_and_handling_fees.map(&:amount).sum
end
def payment_fee
sum_adjustments "payment_fee"
end
def ship_total
sum_adjustments "shipping"
end
private
attr_reader :order
def adjustments
order.all_adjustments
end
def adjustments_eager_loaded?
adjustments.loaded?
end
def sum_adjustments(scope)
collect_adjustments(scope).map(&:amount).sum
end
def collect_adjustments(scope)
if adjustments_eager_loaded?
adjustment_scope = public_send("#{scope}_scope")
# Adjustments are already loaded here, this block is using `Array#select`
adjustments.select do |adjustment|
match_by_scope(adjustment, adjustment_scope) && match_by_scope(adjustment, eligible_scope)
end
else
adjustments.where(nil).eligible.public_send scope
end
end
def admin_and_handling_fees
if adjustments_eager_loaded?
adjustments.select do |adjustment|
match_by_scope(adjustment, eligible_scope) &&
adjustment.originator_type == 'EnterpriseFee' &&
adjustment.adjustable_type != 'Spree::LineItem'
end
else
adjustments.eligible.
where("originator_type = ? AND adjustable_type != ?", 'EnterpriseFee', 'Spree::LineItem')
end
end
def match_by_scope(adjustment, scope)
adjustment.public_send(scope.keys.first) == scope.values.first
end
end

View File

@@ -1,43 +0,0 @@
# frozen_string_literal: true
require 'open_food_network/tag_rule_applicator'
class OrderAvailablePaymentMethods
attr_reader :order, :customer
delegate :distributor,
:order_cycle,
to: :order
def initialize(order, customer = nil)
@order, @customer = order, customer
end
def to_a
return [] if distributor.blank?
payment_methods = payment_methods_before_tag_rules_applied
applicator = OpenFoodNetwork::TagRuleApplicator.new(distributor,
"FilterPaymentMethods", customer&.tag_list)
applicator.filter(payment_methods)
end
private
def payment_methods_before_tag_rules_applied
if order_cycle.nil? || order_cycle.simple?
distributor.payment_methods
else
distributor.payment_methods.where(
id: available_distributor_payment_methods_ids
)
end.available.select(&:configured?).uniq
end
def available_distributor_payment_methods_ids
order_cycle.distributor_payment_methods
.where(distributor_id: distributor.id)
.select(:payment_method_id)
end
end

View File

@@ -1,56 +0,0 @@
# frozen_string_literal: true
require 'open_food_network/tag_rule_applicator'
class OrderAvailableShippingMethods
attr_reader :order, :customer
delegate :distributor, :order_cycle, to: :order
def initialize(order, customer = nil)
@order, @customer = order, customer
end
def to_a
return [] if distributor.blank?
filter_by_category(tag_rules.filter(shipping_methods))
end
private
def filter_by_category(methods)
return methods unless OpenFoodNetwork::FeatureToggle.enabled?(:match_shipping_categories,
distributor&.owner)
required_category_ids = order.variants.pluck(:shipping_category_id).to_set
return methods if required_category_ids.empty?
methods.select do |method|
provided_category_ids = method.shipping_categories.pluck(:id).to_set
required_category_ids.subset?(provided_category_ids)
end
end
def shipping_methods
if order_cycle.nil? || order_cycle.simple?
distributor.shipping_methods
else
distributor.shipping_methods.where(
id: available_distributor_shipping_methods_ids
)
end.frontend.to_a.uniq
end
def available_distributor_shipping_methods_ids
order_cycle.distributor_shipping_methods
.where(distributor_id: distributor.id)
.select(:shipping_method_id)
end
def tag_rules
OpenFoodNetwork::TagRuleApplicator.new(
distributor, "FilterShippingMethods", customer&.tag_list
)
end
end

View File

@@ -1,22 +0,0 @@
# frozen_string_literal: true
# Use `authorize! :admin order` before calling this service
class OrderCaptureService
attr_reader :gateway_error
def initialize(order)
@order = order
@gateway_error = nil
end
def call
return false unless @order.payment_required?
return false unless (pending_payment = @order.pending_payments.first)
pending_payment.capture!
rescue Spree::Core::GatewayError => e
@gateway_error = e
false
end
end

View File

@@ -1,56 +0,0 @@
# frozen_string_literal: false
# Resets an order by verifying it's state and fixing any issues
class OrderCartReset
def initialize(order, distributor_id)
@order = order
@distributor ||= Enterprise.is_distributor.find_by(permalink: distributor_id) ||
Enterprise.is_distributor.find(distributor_id)
end
def reset_distributor
if order.distributor && order.distributor != distributor
order.empty!
order.set_order_cycle! nil
end
order.distributor = distributor
end
def reset_other!(current_user, current_customer)
reset_user_and_customer(current_user)
reset_order_cycle(current_customer)
order.save!
end
private
attr_reader :order, :distributor, :current_user
def reset_user_and_customer(current_user)
return unless current_user
order.associate_user!(current_user) if order.user.blank? || order.email.blank?
end
def reset_order_cycle(current_customer)
listed_order_cycles = Shop::OrderCyclesList.active_for(distributor, current_customer)
if order_cycle_not_listed?(order.order_cycle, listed_order_cycles)
order.order_cycle = nil
order.empty!
end
select_default_order_cycle(order, listed_order_cycles)
end
def order_cycle_not_listed?(order_cycle, listed_order_cycles)
order_cycle.present? && !listed_order_cycles.include?(order_cycle)
end
# If no OC is selected and there is only one in the list of OCs, selects it
def select_default_order_cycle(order, listed_order_cycles)
return unless order.order_cycle.blank? && listed_order_cycles.size == 1
order.order_cycle = listed_order_cycles.first
end
end

View File

@@ -1,34 +0,0 @@
# frozen_string_literal: true
# Resets the passed order to cart state while clearing associated payments and shipments
class OrderCheckoutRestart
def initialize(order)
@order = order
end
def call
return if order.cart?
reset_state_to_cart
clear_shipments
clear_payments
order.reload.update_order!
end
private
attr_reader :order
def reset_state_to_cart
order.restart_checkout!
end
def clear_shipments
order.shipments.with_state(:pending).destroy_all
end
def clear_payments
order.payments.with_state(:checkout).destroy_all
end
end

View File

@@ -1,43 +0,0 @@
# frozen_string_literal: true
class OrderCycleClone
def initialize(order_cycle)
@original_order_cycle = order_cycle
end
def create
oc = @original_order_cycle.dup
oc.name = I18n.t("models.order_cycle.cloned_order_cycle_name", order_cycle: oc.name)
oc.orders_open_at = oc.orders_close_at = oc.mails_sent = oc.processed_at = nil
oc.coordinator_fee_ids = @original_order_cycle.coordinator_fee_ids
oc.preferred_product_selection_from_coordinator_inventory_only =
@original_order_cycle.preferred_product_selection_from_coordinator_inventory_only
oc.schedule_ids = @original_order_cycle.schedule_ids
oc.save!
@original_order_cycle.exchanges.each { |e| e.clone!(oc) }
oc.selected_distributor_payment_method_ids = selected_distributor_payment_method_ids
oc.selected_distributor_shipping_method_ids = selected_distributor_shipping_method_ids
sync_subscriptions
oc.reload
end
private
def selected_distributor_payment_method_ids
@original_order_cycle.attachable_distributor_payment_methods.map(&:id) &
@original_order_cycle.selected_distributor_payment_method_ids
end
def selected_distributor_shipping_method_ids
@original_order_cycle.attachable_distributor_shipping_methods.map(&:id) &
@original_order_cycle.selected_distributor_shipping_method_ids
end
def sync_subscriptions
return unless @original_order_cycle.schedule_ids.any?
OrderManagement::Subscriptions::ProxyOrderSyncer.new(
Subscription.where(schedule_id: @original_order_cycle.schedule_ids)
).sync!
end
end

View File

@@ -1,83 +0,0 @@
# frozen_string_literal: true
# Returns a (paginatable) AR object for the products or variants in stock for a given shop and OC.
# The stock-checking includes on_demand and stock level overrides from variant_overrides.
class OrderCycleDistributedProducts
def initialize(distributor, order_cycle, customer)
@distributor = distributor
@order_cycle = order_cycle
@customer = customer
end
def products_relation
Spree::Product.where(id: stocked_products).group("spree_products.id")
end
def variants_relation
order_cycle.
variants_distributed_by(distributor).
merge(stocked_variants_and_overrides).
select("DISTINCT spree_variants.*")
end
private
attr_reader :distributor, :order_cycle, :customer
def stocked_products
order_cycle.
variants_distributed_by(distributor).
merge(stocked_variants_and_overrides).
select("DISTINCT spree_variants.product_id")
end
def stocked_variants_and_overrides
stocked_variants = Spree::Variant.
joins("LEFT OUTER JOIN variant_overrides ON variant_overrides.variant_id = spree_variants.id
AND variant_overrides.hub_id = #{distributor.id}").
joins(:stock_items).
where(query_stock_with_overrides)
ProductTagRulesFilterer.new(distributor, customer, stocked_variants).call
end
def query_stock_with_overrides
"( #{variant_not_overriden} AND ( #{variant_on_demand} OR #{variant_in_stock} ) )
OR ( #{variant_overriden} AND ( #{override_on_demand} OR #{override_in_stock} ) )
OR ( #{variant_overriden} AND ( #{override_on_demand_null} AND #{variant_on_demand} ) )
OR ( #{variant_overriden} AND ( #{override_on_demand_null}
AND #{variant_not_on_demand} AND #{variant_in_stock} ) )"
end
def variant_not_overriden
"variant_overrides.id IS NULL"
end
def variant_overriden
"variant_overrides.id IS NOT NULL"
end
def variant_in_stock
"spree_stock_items.count_on_hand > 0"
end
def variant_on_demand
"spree_stock_items.backorderable IS TRUE"
end
def variant_not_on_demand
"spree_stock_items.backorderable IS FALSE"
end
def override_on_demand
"variant_overrides.on_demand IS TRUE"
end
def override_in_stock
"variant_overrides.count_on_hand > 0"
end
def override_on_demand_null
"variant_overrides.on_demand IS NULL"
end
end

View File

@@ -1,22 +0,0 @@
# frozen_string_literal: true
class OrderCycleDistributedVariants
def initialize(order_cycle, distributor)
@order_cycle = order_cycle
@distributor = distributor
end
def distributes_order_variants?(order)
unavailable_order_variants(order).empty?
end
def unavailable_order_variants(order)
order.line_item_variants - available_variants
end
def available_variants
return [] unless @order_cycle
@order_cycle.variants_distributed_by(@distributor)
end
end

View File

@@ -1,230 +0,0 @@
# frozen_string_literal: true
require 'open_food_network/permissions'
require 'open_food_network/order_cycle_form_applicator'
class OrderCycleForm
def initialize(order_cycle, order_cycle_params, user)
@order_cycle = order_cycle
@order_cycle_params = order_cycle_params
@specified_params = order_cycle_params.keys
@user = user
@permissions = OpenFoodNetwork::Permissions.new(user)
@schedule_ids = order_cycle_params.delete(:schedule_ids)
@selected_distributor_payment_method_ids = order_cycle_params.delete(
:selected_distributor_payment_method_ids
)
@selected_distributor_shipping_method_ids = order_cycle_params.delete(
:selected_distributor_shipping_method_ids
)
end
def save
schedule_ids = build_schedule_ids
order_cycle.assign_attributes(order_cycle_params)
return false unless order_cycle.valid?
order_cycle.transaction do
order_cycle.save!
order_cycle.schedule_ids = schedule_ids if parameter_specified?(:schedule_ids)
order_cycle.save!
apply_exchange_changes
if can_update_selected_payment_or_shipping_methods?
attach_selected_distributor_payment_methods
attach_selected_distributor_shipping_methods
end
sync_subscriptions
true
end
rescue ActiveRecord::RecordInvalid => e
add_exception_to_order_cycle_errors(e)
false
end
private
attr_accessor :order_cycle, :order_cycle_params, :user, :permissions
def add_exception_to_order_cycle_errors(exception)
error = exception.message.split(":").last.strip
order_cycle.errors.add(:base, error) if order_cycle.errors.to_a.exclude?(error)
end
def apply_exchange_changes
return if exchanges_unchanged?
OpenFoodNetwork::OrderCycleFormApplicator.new(order_cycle, user).go!
# reload so outgoing exchanges are up-to-date for shipping/payment method validations
order_cycle.reload
end
def attach_selected_distributor_payment_methods
return if @selected_distributor_payment_method_ids.nil?
if distributor_only?
payment_method_ids = order_cycle.selected_distributor_payment_method_ids
payment_method_ids -= user_distributor_payment_method_ids
payment_method_ids += user_only_selected_distributor_payment_method_ids
order_cycle.selected_distributor_payment_method_ids = payment_method_ids
else
order_cycle.selected_distributor_payment_method_ids = selected_distributor_payment_method_ids
end
order_cycle.save!
end
def attach_selected_distributor_shipping_methods
return if @selected_distributor_shipping_method_ids.nil?
if distributor_only?
# A distributor can only update methods associated with their own
# enterprise, so we load all previously selected methods, and replace
# only the distributor's methods with their selection (not touching other
# distributor's methods).
shipping_method_ids = order_cycle.selected_distributor_shipping_method_ids
shipping_method_ids -= user_distributor_shipping_method_ids
shipping_method_ids += user_only_selected_distributor_shipping_method_ids
order_cycle.selected_distributor_shipping_method_ids = shipping_method_ids
else
order_cycle.selected_distributor_shipping_method_ids =
selected_distributor_shipping_method_ids
end
order_cycle.save!
end
def attachable_distributor_payment_method_ids
@attachable_distributor_payment_method_ids ||=
order_cycle.attachable_distributor_payment_methods.map(&:id)
end
def attachable_distributor_shipping_method_ids
@attachable_distributor_shipping_method_ids ||=
order_cycle.attachable_distributor_shipping_methods.map(&:id)
end
def exchanges_unchanged?
[:incoming_exchanges, :outgoing_exchanges].all? do |direction|
order_cycle_params[direction].nil?
end
end
def selected_distributor_payment_method_ids
@selected_distributor_payment_method_ids = (
attachable_distributor_payment_method_ids &
@selected_distributor_payment_method_ids.compact_blank.map(&:to_i)
)
if attachable_distributor_payment_method_ids.sort ==
@selected_distributor_payment_method_ids.sort
@selected_distributor_payment_method_ids = []
end
@selected_distributor_payment_method_ids
end
def user_only_selected_distributor_payment_method_ids
user_distributor_payment_method_ids.intersection(selected_distributor_payment_method_ids)
end
def selected_distributor_shipping_method_ids
@selected_distributor_shipping_method_ids = (
attachable_distributor_shipping_method_ids &
@selected_distributor_shipping_method_ids.compact_blank.map(&:to_i)
)
if attachable_distributor_shipping_method_ids.sort ==
@selected_distributor_shipping_method_ids.sort
@selected_distributor_shipping_method_ids = []
end
@selected_distributor_shipping_method_ids
end
def user_only_selected_distributor_shipping_method_ids
user_distributor_shipping_method_ids.intersection(selected_distributor_shipping_method_ids)
end
def build_schedule_ids
return unless parameter_specified?(:schedule_ids)
result = existing_schedule_ids
result |= (requested_schedule_ids & permitted_schedule_ids) # Add permitted and requested
# Remove permitted but not requested
result -= ((result & permitted_schedule_ids) - requested_schedule_ids)
result
end
def sync_subscriptions
return unless parameter_specified?(:schedule_ids)
return unless schedule_sync_required?
OrderManagement::Subscriptions::ProxyOrderSyncer.new(subscriptions_to_sync).sync!
end
def schedule_sync_required?
removed_schedule_ids.any? || new_schedule_ids.any?
end
def subscriptions_to_sync
Subscription.where(schedule_id: removed_schedule_ids + new_schedule_ids)
end
def requested_schedule_ids
@schedule_ids.map(&:to_i)
end
def parameter_specified?(key)
@specified_params.map(&:to_s).include?(key.to_s)
end
def permitted_schedule_ids
Schedule.where(id: requested_schedule_ids | existing_schedule_ids)
.merge(permissions.editable_schedules).pluck(:id)
end
def existing_schedule_ids
@existing_schedule_ids ||= order_cycle.persisted? ? order_cycle.schedule_ids : []
end
def removed_schedule_ids
existing_schedule_ids - order_cycle.schedule_ids
end
def new_schedule_ids
@order_cycle.schedule_ids - existing_schedule_ids
end
def can_update_selected_payment_or_shipping_methods?
@user.admin? || coordinator? || distributor?
end
def coordinator?
@user.enterprises.include?(@order_cycle.coordinator)
end
def distributor?
!user_distributors_ids.empty?
end
def distributor_only?
distributor? && !@user.admin? && !coordinator?
end
def user_distributors_ids
@user_distributors_ids ||= @user.enterprises.pluck(:id)
.intersection(@order_cycle.distributors.pluck(:id))
end
def user_distributor_payment_method_ids
@user_distributor_payment_method_ids ||=
DistributorPaymentMethod.where(distributor_id: user_distributors_ids)
.pluck(:id)
end
def user_distributor_shipping_method_ids
@user_distributor_shipping_method_ids ||=
DistributorShippingMethod.where(distributor_id: user_distributors_ids)
.pluck(:id)
end
end

View File

@@ -1,37 +0,0 @@
# frozen_string_literal: true
class OrderCycleWarning
def initialize(current_user)
@current_user = current_user
end
def call
distributors = active_distributors_not_ready_for_checkout
return if distributors.empty?
active_distributors_not_ready_for_checkout_message(distributors)
end
private
attr_reader :current_user
def active_distributors_not_ready_for_checkout
ocs = OrderCycle.managed_by(current_user).active
distributors = ocs.includes(:distributors).map(&:distributors).flatten.uniq
Enterprise.where(id: distributors.map(&:id)).not_ready_for_checkout
end
def active_distributors_not_ready_for_checkout_message(distributors)
distributor_names = distributors.map(&:name).join ', '
if distributors.count > 1
I18n.t(:active_distributors_not_ready_for_checkout_message_plural,
distributor_names:)
else
I18n.t(:active_distributors_not_ready_for_checkout_message_singular,
distributor_names:)
end
end
end

View File

@@ -1,21 +0,0 @@
# frozen_string_literal: true
# Create a webhook payload for an order cycle event.
# The payload will be delivered asynchronously.
class OrderCycleWebhookService
def self.create_webhook_job(order_cycle, event)
webhook_payload = order_cycle
.slice(:id, :name, :orders_open_at, :orders_close_at, :coordinator_id)
.merge(coordinator_name: order_cycle.coordinator.name)
# Endpoints for coordinator owner
webhook_endpoints = order_cycle.coordinator.owner.webhook_endpoints
# Plus unique endpoints for distributor owners (ignore duplicates)
webhook_endpoints |= order_cycle.distributors.map(&:owner).flat_map(&:webhook_endpoints)
webhook_endpoints.each do |endpoint|
WebhookDeliveryJob.perform_later(endpoint.url, event, webhook_payload)
end
end
end

View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
module OrderCycles
class CloneService
def initialize(order_cycle)
@original_order_cycle = order_cycle
end
def create
oc = @original_order_cycle.dup
oc.name = I18n.t("models.order_cycle.cloned_order_cycle_name", order_cycle: oc.name)
oc.orders_open_at = oc.orders_close_at = oc.mails_sent = oc.processed_at = nil
oc.coordinator_fee_ids = @original_order_cycle.coordinator_fee_ids
oc.preferred_product_selection_from_coordinator_inventory_only =
@original_order_cycle.preferred_product_selection_from_coordinator_inventory_only
oc.schedule_ids = @original_order_cycle.schedule_ids
oc.save!
@original_order_cycle.exchanges.each { |e| e.clone!(oc) }
oc.selected_distributor_payment_method_ids = selected_distributor_payment_method_ids
oc.selected_distributor_shipping_method_ids = selected_distributor_shipping_method_ids
sync_subscriptions
oc.reload
end
private
def selected_distributor_payment_method_ids
@original_order_cycle.attachable_distributor_payment_methods.map(&:id) &
@original_order_cycle.selected_distributor_payment_method_ids
end
def selected_distributor_shipping_method_ids
@original_order_cycle.attachable_distributor_shipping_methods.map(&:id) &
@original_order_cycle.selected_distributor_shipping_method_ids
end
def sync_subscriptions
return unless @original_order_cycle.schedule_ids.any?
OrderManagement::Subscriptions::ProxyOrderSyncer.new(
Subscription.where(schedule_id: @original_order_cycle.schedule_ids)
).sync!
end
end
end

View File

@@ -0,0 +1,86 @@
# frozen_string_literal: true
# Returns a (paginatable) AR object for the products or variants in stock for a given shop and OC.
# The stock-checking includes on_demand and stock level overrides from variant_overrides.
module OrderCycles
class DistributedProductsService
def initialize(distributor, order_cycle, customer)
@distributor = distributor
@order_cycle = order_cycle
@customer = customer
end
def products_relation
Spree::Product.where(id: stocked_products).group("spree_products.id")
end
def variants_relation
order_cycle.
variants_distributed_by(distributor).
merge(stocked_variants_and_overrides).
select("DISTINCT spree_variants.*")
end
private
attr_reader :distributor, :order_cycle, :customer
def stocked_products
order_cycle.
variants_distributed_by(distributor).
merge(stocked_variants_and_overrides).
select("DISTINCT spree_variants.product_id")
end
def stocked_variants_and_overrides
stocked_variants = Spree::Variant.
joins("LEFT OUTER JOIN variant_overrides ON variant_overrides.variant_id = spree_variants.id
AND variant_overrides.hub_id = #{distributor.id}").
joins(:stock_items).
where(query_stock_with_overrides)
ProductTagRulesFilterer.new(distributor, customer, stocked_variants).call
end
def query_stock_with_overrides
"( #{variant_not_overriden} AND ( #{variant_on_demand} OR #{variant_in_stock} ) )
OR ( #{variant_overriden} AND ( #{override_on_demand} OR #{override_in_stock} ) )
OR ( #{variant_overriden} AND ( #{override_on_demand_null} AND #{variant_on_demand} ) )
OR ( #{variant_overriden} AND ( #{override_on_demand_null}
AND #{variant_not_on_demand} AND #{variant_in_stock} ) )"
end
def variant_not_overriden
"variant_overrides.id IS NULL"
end
def variant_overriden
"variant_overrides.id IS NOT NULL"
end
def variant_in_stock
"spree_stock_items.count_on_hand > 0"
end
def variant_on_demand
"spree_stock_items.backorderable IS TRUE"
end
def variant_not_on_demand
"spree_stock_items.backorderable IS FALSE"
end
def override_on_demand
"variant_overrides.on_demand IS TRUE"
end
def override_in_stock
"variant_overrides.count_on_hand > 0"
end
def override_on_demand_null
"variant_overrides.on_demand IS NULL"
end
end
end

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
module OrderCycles
class DistributedVariantsService
def initialize(order_cycle, distributor)
@order_cycle = order_cycle
@distributor = distributor
end
def distributes_order_variants?(order)
unavailable_order_variants(order).empty?
end
def unavailable_order_variants(order)
order.line_item_variants - available_variants
end
def available_variants
return [] unless @order_cycle
@order_cycle.variants_distributed_by(@distributor)
end
end
end

View File

@@ -0,0 +1,233 @@
# frozen_string_literal: true
require 'open_food_network/permissions'
require 'open_food_network/order_cycle_form_applicator'
module OrderCycles
class FormService
def initialize(order_cycle, order_cycle_params, user)
@order_cycle = order_cycle
@order_cycle_params = order_cycle_params
@specified_params = order_cycle_params.keys
@user = user
@permissions = OpenFoodNetwork::Permissions.new(user)
@schedule_ids = order_cycle_params.delete(:schedule_ids)
@selected_distributor_payment_method_ids = order_cycle_params.delete(
:selected_distributor_payment_method_ids
)
@selected_distributor_shipping_method_ids = order_cycle_params.delete(
:selected_distributor_shipping_method_ids
)
end
def save
schedule_ids = build_schedule_ids
order_cycle.assign_attributes(order_cycle_params)
return false unless order_cycle.valid?
order_cycle.transaction do
order_cycle.save!
order_cycle.schedule_ids = schedule_ids if parameter_specified?(:schedule_ids)
order_cycle.save!
apply_exchange_changes
if can_update_selected_payment_or_shipping_methods?
attach_selected_distributor_payment_methods
attach_selected_distributor_shipping_methods
end
sync_subscriptions
true
end
rescue ActiveRecord::RecordInvalid => e
add_exception_to_order_cycle_errors(e)
false
end
private
attr_accessor :order_cycle, :order_cycle_params, :user, :permissions
def add_exception_to_order_cycle_errors(exception)
error = exception.message.split(":").last.strip
order_cycle.errors.add(:base, error) if order_cycle.errors.to_a.exclude?(error)
end
def apply_exchange_changes
return if exchanges_unchanged?
OpenFoodNetwork::OrderCycleFormApplicator.new(order_cycle, user).go!
# reload so outgoing exchanges are up-to-date for shipping/payment method validations
order_cycle.reload
end
def attach_selected_distributor_payment_methods
return if @selected_distributor_payment_method_ids.nil?
if distributor_only?
payment_method_ids = order_cycle.selected_distributor_payment_method_ids
payment_method_ids -= user_distributor_payment_method_ids
payment_method_ids += user_only_selected_distributor_payment_method_ids
order_cycle.selected_distributor_payment_method_ids = payment_method_ids
else
order_cycle
.selected_distributor_payment_method_ids = selected_distributor_payment_method_ids
end
order_cycle.save!
end
def attach_selected_distributor_shipping_methods
return if @selected_distributor_shipping_method_ids.nil?
if distributor_only?
# A distributor can only update methods associated with their own
# enterprise, so we load all previously selected methods, and replace
# only the distributor's methods with their selection (not touching other
# distributor's methods).
shipping_method_ids = order_cycle.selected_distributor_shipping_method_ids
shipping_method_ids -= user_distributor_shipping_method_ids
shipping_method_ids += user_only_selected_distributor_shipping_method_ids
order_cycle.selected_distributor_shipping_method_ids = shipping_method_ids
else
order_cycle.selected_distributor_shipping_method_ids =
selected_distributor_shipping_method_ids
end
order_cycle.save!
end
def attachable_distributor_payment_method_ids
@attachable_distributor_payment_method_ids ||=
order_cycle.attachable_distributor_payment_methods.map(&:id)
end
def attachable_distributor_shipping_method_ids
@attachable_distributor_shipping_method_ids ||=
order_cycle.attachable_distributor_shipping_methods.map(&:id)
end
def exchanges_unchanged?
[:incoming_exchanges, :outgoing_exchanges].all? do |direction|
order_cycle_params[direction].nil?
end
end
def selected_distributor_payment_method_ids
@selected_distributor_payment_method_ids = (
attachable_distributor_payment_method_ids &
@selected_distributor_payment_method_ids.compact_blank.map(&:to_i)
)
if attachable_distributor_payment_method_ids.sort ==
@selected_distributor_payment_method_ids.sort
@selected_distributor_payment_method_ids = []
end
@selected_distributor_payment_method_ids
end
def user_only_selected_distributor_payment_method_ids
user_distributor_payment_method_ids.intersection(selected_distributor_payment_method_ids)
end
def selected_distributor_shipping_method_ids
@selected_distributor_shipping_method_ids = (
attachable_distributor_shipping_method_ids &
@selected_distributor_shipping_method_ids.compact_blank.map(&:to_i)
)
if attachable_distributor_shipping_method_ids.sort ==
@selected_distributor_shipping_method_ids.sort
@selected_distributor_shipping_method_ids = []
end
@selected_distributor_shipping_method_ids
end
def user_only_selected_distributor_shipping_method_ids
user_distributor_shipping_method_ids.intersection(selected_distributor_shipping_method_ids)
end
def build_schedule_ids
return unless parameter_specified?(:schedule_ids)
result = existing_schedule_ids
result |= (requested_schedule_ids & permitted_schedule_ids) # Add permitted and requested
# Remove permitted but not requested
result -= ((result & permitted_schedule_ids) - requested_schedule_ids)
result
end
def sync_subscriptions
return unless parameter_specified?(:schedule_ids)
return unless schedule_sync_required?
OrderManagement::Subscriptions::ProxyOrderSyncer.new(subscriptions_to_sync).sync!
end
def schedule_sync_required?
removed_schedule_ids.any? || new_schedule_ids.any?
end
def subscriptions_to_sync
Subscription.where(schedule_id: removed_schedule_ids + new_schedule_ids)
end
def requested_schedule_ids
@schedule_ids.map(&:to_i)
end
def parameter_specified?(key)
@specified_params.map(&:to_s).include?(key.to_s)
end
def permitted_schedule_ids
Schedule.where(id: requested_schedule_ids | existing_schedule_ids)
.merge(permissions.editable_schedules).pluck(:id)
end
def existing_schedule_ids
@existing_schedule_ids ||= order_cycle.persisted? ? order_cycle.schedule_ids : []
end
def removed_schedule_ids
existing_schedule_ids - order_cycle.schedule_ids
end
def new_schedule_ids
@order_cycle.schedule_ids - existing_schedule_ids
end
def can_update_selected_payment_or_shipping_methods?
@user.admin? || coordinator? || distributor?
end
def coordinator?
@user.enterprises.include?(@order_cycle.coordinator)
end
def distributor?
!user_distributors_ids.empty?
end
def distributor_only?
distributor? && !@user.admin? && !coordinator?
end
def user_distributors_ids
@user_distributors_ids ||= @user.enterprises.pluck(:id)
.intersection(@order_cycle.distributors.pluck(:id))
end
def user_distributor_payment_method_ids
@user_distributor_payment_method_ids ||=
DistributorPaymentMethod.where(distributor_id: user_distributors_ids)
.pluck(:id)
end
def user_distributor_shipping_method_ids
@user_distributor_shipping_method_ids ||=
DistributorShippingMethod.where(distributor_id: user_distributors_ids)
.pluck(:id)
end
end
end

View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
module OrderCycles
class WarningService
def initialize(current_user)
@current_user = current_user
end
def call
distributors = active_distributors_not_ready_for_checkout
return if distributors.empty?
active_distributors_not_ready_for_checkout_message(distributors)
end
private
attr_reader :current_user
def active_distributors_not_ready_for_checkout
ocs = OrderCycle.managed_by(current_user).active
distributors = ocs.includes(:distributors).map(&:distributors).flatten.uniq
Enterprise.where(id: distributors.map(&:id)).not_ready_for_checkout
end
def active_distributors_not_ready_for_checkout_message(distributors)
distributor_names = distributors.map(&:name).join ', '
if distributors.count > 1
I18n.t(:active_distributors_not_ready_for_checkout_message_plural,
distributor_names:)
else
I18n.t(:active_distributors_not_ready_for_checkout_message_singular,
distributor_names:)
end
end
end
end

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
# Create a webhook payload for an order cycle event.
# The payload will be delivered asynchronously.
module OrderCycles
class WebhookService
def self.create_webhook_job(order_cycle, event)
webhook_payload = order_cycle
.slice(:id, :name, :orders_open_at, :orders_close_at, :coordinator_id)
.merge(coordinator_name: order_cycle.coordinator.name)
# Endpoints for coordinator owner
webhook_endpoints = order_cycle.coordinator.owner.webhook_endpoints
# Plus unique endpoints for distributor owners (ignore duplicates)
webhook_endpoints |= order_cycle.distributors.map(&:owner).flat_map(&:webhook_endpoints)
webhook_endpoints.each do |endpoint|
WebhookDeliveryJob.perform_later(endpoint.url, event, webhook_payload)
end
end
end
end

View File

@@ -1,35 +0,0 @@
# frozen_string_literal: true
class OrderDataMasker
def initialize(order)
@order = order
end
def call
mask_customer_names unless customer_names_allowed?
mask_contact_data
end
private
attr_accessor :order
def customer_names_allowed?
order.distributor.show_customer_names_to_suppliers
end
def mask_customer_names
order.bill_address&.assign_attributes(firstname: I18n.t('admin.reports.hidden'),
lastname: "")
order.ship_address&.assign_attributes(firstname: I18n.t('admin.reports.hidden'),
lastname: "")
end
def mask_contact_data
order.bill_address&.assign_attributes(phone: "", address1: "", address2: "",
city: "", zipcode: "", state: nil)
order.ship_address&.assign_attributes(phone: "", address1: "", address2: "",
city: "", zipcode: "", state: nil)
order.assign_attributes(email: I18n.t('admin.reports.hidden'))
end
end

View File

@@ -1,97 +0,0 @@
# frozen_string_literal: true
require 'open_food_network/scope_variant_to_hub'
# Builds orders based on a set of attributes
# There are some idiosyncracies in the order creation process,
# and it is nice to have them dealt with in one place.
class OrderFactory
def initialize(attrs, opts = {})
@attrs = attrs.with_indifferent_access
@opts = opts.with_indifferent_access
end
def create
create_order
set_user
build_line_items
set_addresses
create_shipment
set_shipping_method
create_payment
@order
end
private
attr_reader :attrs, :opts
def customer
@customer ||= Customer.find(attrs[:customer_id])
end
def shop
@shop ||= Enterprise.find(attrs[:distributor_id])
end
def create_order
@order = Spree::Order.create!(create_attrs)
end
def create_attrs
create_attrs = attrs.slice(:customer_id, :order_cycle_id, :distributor_id)
create_attrs[:email] = customer.email
create_attrs
end
def build_line_items
attrs[:line_items].each do |li|
next unless variant = Spree::Variant.find_by(id: li[:variant_id])
scoper.scope(variant)
li[:quantity] = stock_limited_quantity(variant.on_demand, variant.on_hand, li[:quantity])
li[:price] = variant.price
build_item_from(li)
end
end
def build_item_from(attrs)
@order.line_items.build(
attrs.merge(skip_stock_check: opts[:skip_stock_check])
)
end
def set_user
@order.update_attribute(:user_id, customer.user_id)
end
def set_addresses
@order.update(attrs.slice(:bill_address_attributes, :ship_address_attributes))
end
def create_shipment
@order.create_proposed_shipments
end
def set_shipping_method
@order.select_shipping_method(attrs[:shipping_method_id])
end
def create_payment
@order.recreate_all_fees!
@order.payments.create(payment_method_id: attrs[:payment_method_id],
amount: @order.reload.total)
end
def stock_limited_quantity(variant_on_demand, variant_on_hand, requested)
return requested if opts[:skip_stock_check] || variant_on_demand
[variant_on_hand, requested].min
end
def scoper
@scoper ||= OpenFoodNetwork::ScopeVariantToHub.new(shop)
end
end

View File

@@ -1,68 +0,0 @@
# frozen_string_literal: true
class OrderFeesHandler
attr_reader :order
delegate :distributor, :order_cycle, to: :order
def initialize(order)
@order = order
end
def recreate_all_fees!
# `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
order.with_lock do
EnterpriseFee.clear_all_adjustments order
create_line_item_fees!
create_order_fees!
end
tax_enterprise_fees! unless order.before_payment_state?
order.update_order!
end
def create_line_item_fees!
order.line_items.includes(variant: :product).each do |line_item|
if provided_by_order_cycle? line_item
calculator.create_line_item_adjustments_for line_item
end
end
end
def create_order_fees!
return unless order_cycle
calculator.create_order_adjustments_for order
end
def tax_enterprise_fees!
Spree::TaxRate.adjust(order, order.all_adjustments.enterprise_fee)
end
def update_line_item_fees!(line_item)
line_item.adjustments.enterprise_fee.each do |fee|
fee.update_adjustment!(line_item, force: true)
end
end
def update_order_fees!
order.adjustments.enterprise_fee.where(adjustable_type: 'Spree::Order').each do |fee|
fee.update_adjustment!(order, force: true)
end
end
private
def calculator
@calculator ||= OpenFoodNetwork::EnterpriseFeeCalculator.new(distributor, order_cycle)
end
def provided_by_order_cycle?(line_item)
@order_cycle_variant_ids ||= order_cycle&.variants&.map(&:id) || []
@order_cycle_variant_ids.include? line_item.variant_id
end
end

View File

@@ -1,86 +0,0 @@
# frozen_string_literal: true
class OrderInvoiceComparator
attr_reader :order
def initialize(order)
@order = order
end
def can_generate_new_invoice?
return true if invoices.empty?
# We'll use a recursive BFS algorithm to find if the invoice is outdated
# the root will be the order
# On each node, we'll a list of relevant attributes that will be used on the comparison
different?(current_state_invoice, latest_invoice, invoice_generation_selector)
end
def can_update_latest_invoice?
return false if invoices.empty?
different?(current_state_invoice, latest_invoice, invoice_update_selector)
end
private
def different?(node1, node2, attributes_selector)
simple_values1, presenters1 = attributes_selector.call(node1)
simple_values2, presenters2 = attributes_selector.call(node2)
return true if simple_values1 != simple_values2
return true if presenters1.size != presenters2.size
presenters1.zip(presenters2).any? do |presenter1, presenter2|
different?(presenter1, presenter2, attributes_selector)
end
end
def invoice_generation_selector
values_selector(:invoice_generation_values)
end
def invoice_update_selector
values_selector(:invoice_update_values)
end
def values_selector(attribute)
proc do |node|
return [[], []] unless node.respond_to?(attribute)
grouped = node.public_send(attribute).group_by(&grouper)
[grouped[:simple] || [], grouped[:presenters]&.flatten || []]
end
end
def grouper
proc do |value|
if value.is_a?(Array) || value.class.to_s.starts_with?("Invoice::DataPresenter")
:presenters
else
:simple
end
end
end
def current_state_invoice
@current_state_invoice ||= Invoice.new(
order:,
data: serialize_for_invoice,
date: Time.zone.today,
number: invoices.count + 1
).presenter
end
def invoices
order.invoices
end
def latest_invoice
@latest_invoice ||= invoices.first.presenter
end
def serialize_for_invoice
InvoiceDataGenerator.new(order).serialize_for_invoice
end
end

View File

@@ -1,38 +0,0 @@
# frozen_string_literal: true
class OrderInvoiceGenerator
def initialize(order)
@order = order
end
def generate_or_update_latest_invoice
if comparator.can_generate_new_invoice?
order.invoices.create!(
date: Time.zone.today,
number: total_invoices_created_by_distributor + 1,
data: invoice_data
)
elsif comparator.can_update_latest_invoice?
order.invoices.latest.update!(
date: Time.zone.today,
data: invoice_data
)
end
end
private
attr_reader :order
def comparator
@comparator ||= OrderInvoiceComparator.new(order)
end
def invoice_data
@invoice_data ||= InvoiceDataGenerator.new(order).generate
end
def total_invoices_created_by_distributor
Invoice.joins(:order).where(order: { distributor: order.distributor }).count
end
end

View File

@@ -1,28 +0,0 @@
# frozen_string_literal: true
class OrderPaymentFinder
def initialize(order)
@order = order
end
def last_payment
last(@order.payments)
end
def last_pending_payment
last(@order.pending_payments)
end
private
# `max_by` avoids additional database queries when payments are loaded
# already. There is usually only one payment and this shouldn't cause
# any overhead compared to `order(:created_at).last`. Using `last`
# without order is not deterministic.
#
# We are not using `updated_at` because all payments are touched when the
# order is updated and then all payments have the same `updated_at` value.
def last(payments)
payments.max_by(&:created_at)
end
end

View File

@@ -1,138 +0,0 @@
# frozen_string_literal: true
# Responsible for ensuring that any updates to a Subscription are propagated to any
# orders belonging to that Subscription which have been instantiated
class OrderSyncer
attr_reader :order_update_issues
def initialize(subscription)
@subscription = subscription
@order_update_issues = OrderUpdateIssues.new
@line_item_syncer = LineItemSyncer.new(subscription, order_update_issues)
end
def sync!
orders_in_order_cycles_not_closed.all? do |order|
order.assign_attributes(customer_id:, email: customer&.email,
distributor_id: shop_id)
update_associations_for(order)
line_item_syncer.sync!(order)
order.update_order!
order.save
end
end
private
attr_reader :subscription, :line_item_syncer
delegate :orders, :bill_address, :ship_address, :subscription_line_items, to: :subscription
delegate :shop_id, :customer, :customer_id, to: :subscription
delegate :shipping_method, :shipping_method_id,
:payment_method, :payment_method_id, to: :subscription
delegate :shipping_method_id_changed?, :shipping_method_id_was, to: :subscription
delegate :payment_method_id_changed?, :payment_method_id_was, to: :subscription
def update_associations_for(order)
update_bill_address_for(order) if (bill_address.changes.keys & relevant_address_attrs).any?
update_shipment_for(order) if shipping_method_id_changed?
update_ship_address_for(order)
update_payment_for(order) if payment_method_id_changed?
end
def orders_in_order_cycles_not_closed
return @orders_in_order_cycles_not_closed unless @orders_in_order_cycles_not_closed.nil?
@orders_in_order_cycles_not_closed = orders.joins(:order_cycle).
merge(OrderCycle.not_closed).readonly(false)
end
def update_bill_address_for(order)
unless addresses_match?(order.bill_address, bill_address)
return order_update_issues.add(order, I18n.t('bill_address'))
end
order.bill_address.update(bill_address.attributes.slice(*relevant_address_attrs))
end
def update_payment_for(order)
payment = order.payments.
with_state('checkout').where(payment_method_id: payment_method_id_was).last
if payment
payment&.void_transaction!
order.payments.create(payment_method_id:, amount: order.reload.total)
else
unless order.payments.with_state('checkout').where(payment_method_id:).any?
order_update_issues.add(order, I18n.t('admin.payment_method'))
end
end
end
def update_shipment_for(order)
return if pending_shipment_with?(order, shipping_method_id) # No need to do anything.
if pending_shipment_with?(order, shipping_method_id_was)
order.select_shipping_method(shipping_method_id)
else
order_update_issues.add(order, I18n.t('admin.shipping_method'))
end
end
def update_ship_address_for(order)
# The conditions here are to achieve the same behaviour in earlier versions of Spree, where
# switching from pick-up to delivery affects whether simultaneous changes to shipping address
# are ignored or not.
pickup_to_delivery = force_ship_address_required?(order)
if (!pickup_to_delivery || order.shipment.present?) &&
(ship_address.changes.keys & relevant_address_attrs).any?
save_ship_address_in_order(order)
end
return unless !pickup_to_delivery || order.shipment.blank?
order.updater.shipping_address_from_distributor
end
def relevant_address_attrs
["firstname", "lastname", "address1", "zipcode", "city", "state_id", "country_id", "phone"]
end
def addresses_match?(order_address, subscription_address)
relevant_address_attrs.all? do |attr|
order_address[attr] == subscription_address.public_send("#{attr}_was") ||
order_address[attr] == subscription_address[attr]
end
end
def ship_address_updatable?(order)
return true if force_ship_address_required?(order)
return false unless order.shipping_method.require_ship_address?
return true if addresses_match?(order.ship_address, ship_address)
order_update_issues.add(order, I18n.t('ship_address'))
false
end
# This returns true when the shipping method on the subscription has changed
# to a delivery (ie. a shipping address is required) AND the existing shipping
# address on the order matches the shop's address
def force_ship_address_required?(order)
return false unless shipping_method.require_ship_address?
distributor_address = order.address_from_distributor
relevant_address_attrs.all? do |attr|
order.ship_address[attr] == distributor_address[attr]
end
end
def save_ship_address_in_order(order)
return unless ship_address_updatable?(order)
order.ship_address.update(ship_address.attributes.slice(*relevant_address_attrs))
end
def pending_shipment_with?(order, shipping_method_id)
return false unless order.shipment.present? && order.shipment.state == "pending"
order.shipping_method.id == shipping_method_id
end
end

View File

@@ -1,20 +0,0 @@
# frozen_string_literal: true
# Collects Tax Adjustments related to an order, and returns a hash with a total for each rate.
class OrderTaxAdjustmentsFetcher
def initialize(order)
@order = order
end
def totals(tax_adjustments = order.all_adjustments.tax)
tax_adjustments.each_with_object({}) do |adjustment, hash|
tax_rate = adjustment.originator
hash[tax_rate] = hash[tax_rate].to_f + adjustment.amount
end
end
private
attr_reader :order
end

View File

@@ -1,21 +0,0 @@
# frozen_string_literal: true
# Wrapper for a hash of issues encountered by instances of OrderSyncer and LineItemSyncer
# Used to report issues to the user when they attempt to update a subscription
class OrderUpdateIssues
def initialize
@issues = {}
end
delegate :[], :keys, to: :issues
def add(order, issue)
@issues[order.id] ||= []
@issues[order.id] << issue
end
private
attr_reader :issues
end

View File

@@ -1,95 +0,0 @@
# frozen_string_literal: true
class OrderWorkflow
attr_reader :order
def initialize(order)
@order = order
end
def complete
advance_to_state("complete", advance_order_options)
end
def complete!
advance_order!(advance_order_options)
end
def next(options = {})
result = advance_order_one_step
after_transition_hook(options)
result
end
def advance_to_payment
return unless order.before_payment_state?
advance_to_state("payment", advance_order_options)
end
def advance_checkout(options = {})
advance_to = order.before_payment_state? ? "payment" : "confirmation"
advance_to_state(advance_to, advance_order_options.merge(options))
end
private
def advance_order_options
shipping_method_id = order.shipping_method.id if order.shipping_method.present?
{ "shipping_method_id" => shipping_method_id }
end
def advance_to_state(target_state, options = {})
until order.state == target_state
break unless order.next
after_transition_hook(options)
end
order.state == target_state
end
def advance_order!(options)
until order.completed?
order.next!
after_transition_hook(options)
end
end
def advance_order_one_step
tries ||= 3
order.next
rescue ActiveRecord::StaleObjectError
retry unless (tries -= 1).zero?
false
end
def after_transition_hook(options)
if order.state == "delivery"
order.select_shipping_method(options["shipping_method_id"])
end
persist_all_payments if order.state == "payment"
end
# When a payment fails, the order state machine stays in 'payment' and rollbacks all transactions
# This rollback also reverts the payment state from 'failed', 'void' or 'invalid' to 'pending'
# Despite the rollback, the in-memory payment still has the correct state, so we persist it
def persist_all_payments
order.payments.each do |payment|
in_memory_payment_state = payment.state
if different_from_db_payment_state?(in_memory_payment_state, payment.id)
payment.reload.update(state: in_memory_payment_state)
end
end
end
# Verifies if the in-memory payment state is different from the one stored in the database
# This is be done without reloading the payment so that in-memory data is not changed
def different_from_db_payment_state?(in_memory_payment_state, payment_id)
in_memory_payment_state != Spree::Payment.find(payment_id).state
end
end

View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
require 'open_food_network/tag_rule_applicator'
module Orders
class AvailablePaymentMethodsService
attr_reader :order, :customer
delegate :distributor,
:order_cycle,
to: :order
def initialize(order, customer = nil)
@order, @customer = order, customer
end
def to_a
return [] if distributor.blank?
payment_methods = payment_methods_before_tag_rules_applied
applicator = OpenFoodNetwork::TagRuleApplicator
.new(distributor, "FilterPaymentMethods", customer&.tag_list)
applicator.filter(payment_methods)
end
private
def payment_methods_before_tag_rules_applied
if order_cycle.nil? || order_cycle.simple?
distributor.payment_methods
else
distributor.payment_methods.where(
id: available_distributor_payment_methods_ids
)
end.available.select(&:configured?).uniq
end
def available_distributor_payment_methods_ids
order_cycle.distributor_payment_methods
.where(distributor_id: distributor.id)
.select(:payment_method_id)
end
end
end

View File

@@ -0,0 +1,58 @@
# frozen_string_literal: true
require 'open_food_network/tag_rule_applicator'
module Orders
class AvailableShippingMethodsService
attr_reader :order, :customer
delegate :distributor, :order_cycle, to: :order
def initialize(order, customer = nil)
@order, @customer = order, customer
end
def to_a
return [] if distributor.blank?
filter_by_category(tag_rules.filter(shipping_methods))
end
private
def filter_by_category(methods)
return methods unless OpenFoodNetwork::FeatureToggle.enabled?(:match_shipping_categories,
distributor&.owner)
required_category_ids = order.variants.pluck(:shipping_category_id).to_set
return methods if required_category_ids.empty?
methods.select do |method|
provided_category_ids = method.shipping_categories.pluck(:id).to_set
required_category_ids.subset?(provided_category_ids)
end
end
def shipping_methods
if order_cycle.nil? || order_cycle.simple?
distributor.shipping_methods
else
distributor.shipping_methods.where(
id: available_distributor_shipping_methods_ids
)
end.frontend.to_a.uniq
end
def available_distributor_shipping_methods_ids
order_cycle.distributor_shipping_methods
.where(distributor_id: distributor.id)
.select(:shipping_method_id)
end
def tag_rules
OpenFoodNetwork::TagRuleApplicator.new(
distributor, "FilterShippingMethods", customer&.tag_list
)
end
end
end

View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
module Orders
class BulkCancelService
def initialize(params, current_user)
@order_ids = params[:bulk_ids]
@current_user = current_user
@send_cancellation_email = params[:send_cancellation_email]
@restock_items = params[:restock_items]
end
def call
editable_orders.where(id: @order_ids).each do |order|
order.send_cancellation_email = @send_cancellation_email
order.restock_items = @restock_items
order.cancel
end
end
private
def editable_orders
Permissions::Order.new(@current_user).editable_orders
end
end
end

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
# Use `authorize! :admin order` before calling this service
module Orders
class CaptureService
attr_reader :gateway_error
def initialize(order)
@order = order
@gateway_error = nil
end
def call
return false unless @order.payment_required?
return false unless (pending_payment = @order.pending_payments.first)
pending_payment.capture!
rescue Spree::Core::GatewayError => e
@gateway_error = e
false
end
end
end

View File

@@ -0,0 +1,59 @@
# frozen_string_literal: false
# Resets an order by verifying it's state and fixing any issues
module Orders
class CartResetService
def initialize(order, distributor_id)
@order = order
@distributor ||= Enterprise.is_distributor.find_by(permalink: distributor_id) ||
Enterprise.is_distributor.find(distributor_id)
end
def reset_distributor
if order.distributor && order.distributor != distributor
order.empty!
order.set_order_cycle! nil
end
order.distributor = distributor
end
def reset_other!(current_user, current_customer)
reset_user_and_customer(current_user)
reset_order_cycle(current_customer)
order.save!
end
private
attr_reader :order, :distributor, :current_user
def reset_user_and_customer(current_user)
return unless current_user
order.associate_user!(current_user) if order.user.blank? || order.email.blank?
end
def reset_order_cycle(current_customer)
listed_order_cycles = Shop::OrderCyclesList.active_for(distributor, current_customer)
if order_cycle_not_listed?(order.order_cycle, listed_order_cycles)
order.order_cycle = nil
order.empty!
end
select_default_order_cycle(order, listed_order_cycles)
end
def order_cycle_not_listed?(order_cycle, listed_order_cycles)
order_cycle.present? && listed_order_cycles.exclude?(order_cycle)
end
# If no OC is selected and there is only one in the list of OCs, selects it
def select_default_order_cycle(order, listed_order_cycles)
return unless order.order_cycle.blank? && listed_order_cycles.size == 1
order.order_cycle = listed_order_cycles.first
end
end
end

View File

@@ -0,0 +1,37 @@
# frozen_string_literal: true
# Resets the passed order to cart state while clearing associated payments and shipments
module Orders
class CheckoutRestartService
def initialize(order)
@order = order
end
def call
return if order.cart?
reset_state_to_cart
clear_shipments
clear_payments
order.reload.update_order!
end
private
attr_reader :order
def reset_state_to_cart
order.restart_checkout!
end
def clear_shipments
order.shipments.with_state(:pending).destroy_all
end
def clear_payments
order.payments.with_state(:checkout).destroy_all
end
end
end

View File

@@ -0,0 +1,88 @@
# frozen_string_literal: true
module Orders
class CompareInvoiceService
attr_reader :order
def initialize(order)
@order = order
end
def can_generate_new_invoice?
return true if invoices.empty?
# We'll use a recursive BFS algorithm to find if the invoice is outdated
# the root will be the order
# On each node, we'll a list of relevant attributes that will be used on the comparison
different?(current_state_invoice, latest_invoice, invoice_generation_selector)
end
def can_update_latest_invoice?
return false if invoices.empty?
different?(current_state_invoice, latest_invoice, invoice_update_selector)
end
private
def different?(node1, node2, attributes_selector)
simple_values1, presenters1 = attributes_selector.call(node1)
simple_values2, presenters2 = attributes_selector.call(node2)
return true if simple_values1 != simple_values2
return true if presenters1.size != presenters2.size
presenters1.zip(presenters2).any? do |presenter1, presenter2|
different?(presenter1, presenter2, attributes_selector)
end
end
def invoice_generation_selector
values_selector(:invoice_generation_values)
end
def invoice_update_selector
values_selector(:invoice_update_values)
end
def values_selector(attribute)
proc do |node|
return [[], []] unless node.respond_to?(attribute)
grouped = node.public_send(attribute).group_by(&grouper)
[grouped[:simple] || [], grouped[:presenters]&.flatten || []]
end
end
def grouper
proc do |value|
if value.is_a?(Array) || value.class.to_s.starts_with?("Invoice::DataPresenter")
:presenters
else
:simple
end
end
end
def current_state_invoice
@current_state_invoice ||= Invoice.new(
order:,
data: serialize_for_invoice,
date: Time.zone.today,
number: invoices.count + 1
).presenter
end
def invoices
order.invoices
end
def latest_invoice
@latest_invoice ||= invoices.first.presenter
end
def serialize_for_invoice
InvoiceDataGenerator.new(order).serialize_for_invoice
end
end
end

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
module Orders
class CustomerCancellationService
def initialize(order)
@order = order
end
def call
return unless order.cancel
Spree::OrderMailer.cancel_email_for_shop(order).deliver_later
end
private
attr_reader :order
end
end

View File

@@ -0,0 +1,99 @@
# frozen_string_literal: true
require 'open_food_network/scope_variant_to_hub'
# Builds orders based on a set of attributes
# There are some idiosyncracies in the order creation process,
# and it is nice to have them dealt with in one place.
module Orders
class FactoryService
def initialize(attrs, opts = {})
@attrs = attrs.with_indifferent_access
@opts = opts.with_indifferent_access
end
def create
create_order
set_user
build_line_items
set_addresses
create_shipment
set_shipping_method
create_payment
@order
end
private
attr_reader :attrs, :opts
def customer
@customer ||= Customer.find(attrs[:customer_id])
end
def shop
@shop ||= Enterprise.find(attrs[:distributor_id])
end
def create_order
@order = Spree::Order.create!(create_attrs)
end
def create_attrs
create_attrs = attrs.slice(:customer_id, :order_cycle_id, :distributor_id)
create_attrs[:email] = customer.email
create_attrs
end
def build_line_items
attrs[:line_items].each do |li|
next unless variant = Spree::Variant.find_by(id: li[:variant_id])
scoper.scope(variant)
li[:quantity] = stock_limited_quantity(variant.on_demand, variant.on_hand, li[:quantity])
li[:price] = variant.price
build_item_from(li)
end
end
def build_item_from(attrs)
@order.line_items.build(
attrs.merge(skip_stock_check: opts[:skip_stock_check])
)
end
def set_user
@order.update_attribute(:user_id, customer.user_id)
end
def set_addresses
@order.update(attrs.slice(:bill_address_attributes, :ship_address_attributes))
end
def create_shipment
@order.create_proposed_shipments
end
def set_shipping_method
@order.select_shipping_method(attrs[:shipping_method_id])
end
def create_payment
@order.recreate_all_fees!
@order.payments.create(payment_method_id: attrs[:payment_method_id],
amount: @order.reload.total)
end
def stock_limited_quantity(variant_on_demand, variant_on_hand, requested)
return requested if opts[:skip_stock_check] || variant_on_demand
[variant_on_hand, requested].min
end
def scoper
@scoper ||= OpenFoodNetwork::ScopeVariantToHub.new(shop)
end
end
end

View File

@@ -0,0 +1,81 @@
# frozen_string_literal: true
# This service allows orders with eager-loaded adjustment objects to calculate various adjustment
# types without triggering additional queries.
#
# For example; `order.adjustments.shipping.sum(:amount)` would normally trigger a new query
# regardless of whether or not adjustments have been preloaded, as `#shipping` is an adjustment
# scope, eg; `scope :shipping, where(originator_type: 'Spree::ShippingMethod')`.
#
# Here the adjustment scopes are moved to a shared module, and `adjustments.loaded?` is used to
# check if the objects have already been fetched and initialized. If they have, `order.adjustments`
# will be an Array, and we can select the required objects without hitting the database. If not, it
# will fetch the adjustments via their scopes as normal.
module Orders
class FetchAdjustmentsService
include AdjustmentScopes
def initialize(order)
@order = order
end
def admin_and_handling_total
admin_and_handling_fees.map(&:amount).sum
end
def payment_fee
sum_adjustments "payment_fee"
end
def ship_total
sum_adjustments "shipping"
end
private
attr_reader :order
def adjustments
order.all_adjustments
end
def adjustments_eager_loaded?
adjustments.loaded?
end
def sum_adjustments(scope)
collect_adjustments(scope).map(&:amount).sum
end
def collect_adjustments(scope)
if adjustments_eager_loaded?
adjustment_scope = public_send("#{scope}_scope")
# Adjustments are already loaded here, this block is using `Array#select`
adjustments.select do |adjustment|
match_by_scope(adjustment, adjustment_scope) && match_by_scope(adjustment, eligible_scope)
end
else
adjustments.where(nil).eligible.public_send scope
end
end
def admin_and_handling_fees
if adjustments_eager_loaded?
adjustments.select do |adjustment|
match_by_scope(adjustment, eligible_scope) &&
adjustment.originator_type == 'EnterpriseFee' &&
adjustment.adjustable_type != 'Spree::LineItem'
end
else
adjustments.eligible.
where("originator_type = ? AND adjustable_type != ?", 'EnterpriseFee', 'Spree::LineItem')
end
end
def match_by_scope(adjustment, scope)
adjustment.public_send(scope.keys.first) == scope.values.first
end
end
end

View File

@@ -0,0 +1,22 @@
# frozen_string_literal: true
# Collects Tax Adjustments related to an order, and returns a hash with a total for each rate.
module Orders
class FetchTaxAdjustmentsService
def initialize(order)
@order = order
end
def totals(tax_adjustments = order.all_adjustments.tax)
tax_adjustments.each_with_object({}) do |adjustment, hash|
tax_rate = adjustment.originator
hash[tax_rate] = hash[tax_rate].to_f + adjustment.amount
end
end
private
attr_reader :order
end
end

View File

@@ -0,0 +1,30 @@
# frozen_string_literal: true
module Orders
class FindPaymentService
def initialize(order)
@order = order
end
def last_payment
last(@order.payments)
end
def last_pending_payment
last(@order.pending_payments)
end
private
# `max_by` avoids additional database queries when payments are loaded
# already. There is usually only one payment and this shouldn't cause
# any overhead compared to `order(:created_at).last`. Using `last`
# without order is not deterministic.
#
# We are not using `updated_at` because all payments are touched when the
# order is updated and then all payments have the same `updated_at` value.
def last(payments)
payments.max_by(&:created_at)
end
end
end

View File

@@ -0,0 +1,40 @@
# frozen_string_literal: true
module Orders
class GenerateInvoiceService
def initialize(order)
@order = order
end
def generate_or_update_latest_invoice
if comparator.can_generate_new_invoice?
order.invoices.create!(
date: Time.zone.today,
number: total_invoices_created_by_distributor + 1,
data: invoice_data
)
elsif comparator.can_update_latest_invoice?
order.invoices.latest.update!(
date: Time.zone.today,
data: invoice_data
)
end
end
private
attr_reader :order
def comparator
@comparator ||= Orders::CompareInvoiceService.new(order)
end
def invoice_data
@invoice_data ||= InvoiceDataGenerator.new(order).generate
end
def total_invoices_created_by_distributor
Invoice.joins(:order).where(order: { distributor: order.distributor }).count
end
end
end

View File

@@ -0,0 +1,70 @@
# frozen_string_literal: true
module Orders
class HandleFeesService
attr_reader :order
delegate :distributor, :order_cycle, to: :order
def initialize(order)
@order = order
end
def recreate_all_fees!
# `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
order.with_lock do
EnterpriseFee.clear_all_adjustments order
create_line_item_fees!
create_order_fees!
end
tax_enterprise_fees! unless order.before_payment_state?
order.update_order!
end
def create_line_item_fees!
order.line_items.includes(variant: :product).each do |line_item|
if provided_by_order_cycle? line_item
calculator.create_line_item_adjustments_for line_item
end
end
end
def create_order_fees!
return unless order_cycle
calculator.create_order_adjustments_for order
end
def tax_enterprise_fees!
Spree::TaxRate.adjust(order, order.all_adjustments.enterprise_fee)
end
def update_line_item_fees!(line_item)
line_item.adjustments.enterprise_fee.each do |fee|
fee.update_adjustment!(line_item, force: true)
end
end
def update_order_fees!
order.adjustments.enterprise_fee.where(adjustable_type: 'Spree::Order').each do |fee|
fee.update_adjustment!(order, force: true)
end
end
private
def calculator
@calculator ||= OpenFoodNetwork::EnterpriseFeeCalculator.new(distributor, order_cycle)
end
def provided_by_order_cycle?(line_item)
@order_cycle_variant_ids ||= order_cycle&.variants&.map(&:id) || []
@order_cycle_variant_ids.include? line_item.variant_id
end
end
end

View File

@@ -0,0 +1,37 @@
# frozen_string_literal: true
module Orders
class MaskDataService
def initialize(order)
@order = order
end
def call
mask_customer_names unless customer_names_allowed?
mask_contact_data
end
private
attr_accessor :order
def customer_names_allowed?
order.distributor.show_customer_names_to_suppliers
end
def mask_customer_names
order.bill_address&.assign_attributes(firstname: I18n.t('admin.reports.hidden'),
lastname: "")
order.ship_address&.assign_attributes(firstname: I18n.t('admin.reports.hidden'),
lastname: "")
end
def mask_contact_data
order.bill_address&.assign_attributes(phone: "", address1: "", address2: "",
city: "", zipcode: "", state: nil)
order.ship_address&.assign_attributes(phone: "", address1: "", address2: "",
city: "", zipcode: "", state: nil)
order.assign_attributes(email: I18n.t('admin.reports.hidden'))
end
end
end

View File

@@ -0,0 +1,141 @@
# frozen_string_literal: true
# Responsible for ensuring that any updates to a Subscription are propagated to any
# orders belonging to that Subscription which have been instantiated
module Orders
class SyncService
attr_reader :order_update_issues
def initialize(subscription)
@subscription = subscription
@order_update_issues = Orders::UpdateIssuesService.new
@line_item_syncer = LineItemSyncer.new(subscription, order_update_issues)
end
def sync!
orders_in_order_cycles_not_closed.all? do |order|
order.assign_attributes(customer_id:, email: customer&.email,
distributor_id: shop_id)
update_associations_for(order)
line_item_syncer.sync!(order)
order.update_order!
order.save
end
end
private
attr_reader :subscription, :line_item_syncer
delegate :orders, :bill_address, :ship_address, :subscription_line_items, to: :subscription
delegate :shop_id, :customer, :customer_id, to: :subscription
delegate :shipping_method, :shipping_method_id,
:payment_method, :payment_method_id, to: :subscription
delegate :shipping_method_id_changed?, :shipping_method_id_was, to: :subscription
delegate :payment_method_id_changed?, :payment_method_id_was, to: :subscription
def update_associations_for(order)
update_bill_address_for(order) if bill_address.changes.keys.intersect?(relevant_address_attrs)
update_shipment_for(order) if shipping_method_id_changed?
update_ship_address_for(order)
update_payment_for(order) if payment_method_id_changed?
end
def orders_in_order_cycles_not_closed
return @orders_in_order_cycles_not_closed unless @orders_in_order_cycles_not_closed.nil?
@orders_in_order_cycles_not_closed = orders.joins(:order_cycle).
merge(OrderCycle.not_closed).readonly(false)
end
def update_bill_address_for(order)
unless addresses_match?(order.bill_address, bill_address)
return order_update_issues.add(order, I18n.t('bill_address'))
end
order.bill_address.update(bill_address.attributes.slice(*relevant_address_attrs))
end
def update_payment_for(order)
payment = order.payments.
with_state('checkout').where(payment_method_id: payment_method_id_was).last
if payment
payment&.void_transaction!
order.payments.create(payment_method_id:, amount: order.reload.total)
else
unless order.payments.with_state('checkout').where(payment_method_id:).any?
order_update_issues.add(order, I18n.t('admin.payment_method'))
end
end
end
def update_shipment_for(order)
return if pending_shipment_with?(order, shipping_method_id) # No need to do anything.
if pending_shipment_with?(order, shipping_method_id_was)
order.select_shipping_method(shipping_method_id)
else
order_update_issues.add(order, I18n.t('admin.shipping_method'))
end
end
def update_ship_address_for(order)
# The conditions here are to achieve the same behaviour in earlier versions of Spree, where
# switching from pick-up to delivery affects whether simultaneous changes to shipping address
# are ignored or not.
pickup_to_delivery = force_ship_address_required?(order)
if (!pickup_to_delivery || order.shipment.present?) &&
ship_address.changes.keys.intersect?(relevant_address_attrs)
save_ship_address_in_order(order)
end
return unless !pickup_to_delivery || order.shipment.blank?
order.updater.shipping_address_from_distributor
end
def relevant_address_attrs
["firstname", "lastname", "address1", "zipcode", "city", "state_id", "country_id", "phone"]
end
def addresses_match?(order_address, subscription_address)
relevant_address_attrs.all? do |attr|
order_address[attr] == subscription_address.public_send("#{attr}_was") ||
order_address[attr] == subscription_address[attr]
end
end
def ship_address_updatable?(order)
return true if force_ship_address_required?(order)
return false unless order.shipping_method.require_ship_address?
return true if addresses_match?(order.ship_address, ship_address)
order_update_issues.add(order, I18n.t('ship_address'))
false
end
# This returns true when the shipping method on the subscription has changed
# to a delivery (ie. a shipping address is required) AND the existing shipping
# address on the order matches the shop's address
def force_ship_address_required?(order)
return false unless shipping_method.require_ship_address?
distributor_address = order.address_from_distributor
relevant_address_attrs.all? do |attr|
order.ship_address[attr] == distributor_address[attr]
end
end
def save_ship_address_in_order(order)
return unless ship_address_updatable?(order)
order.ship_address.update(ship_address.attributes.slice(*relevant_address_attrs))
end
def pending_shipment_with?(order, shipping_method_id)
return false unless order.shipment.present? && order.shipment.state == "pending"
order.shipping_method.id == shipping_method_id
end
end
end

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
# Wrapper for a hash of issues encountered by instances of Orders::SyncService and LineItemSyncer
# Used to report issues to the user when they attempt to update a subscription
module Orders
class UpdateIssuesService
def initialize
@issues = {}
end
delegate :[], :keys, to: :issues
def add(order, issue)
@issues[order.id] ||= []
@issues[order.id] << issue
end
private
attr_reader :issues
end
end

View File

@@ -0,0 +1,98 @@
# frozen_string_literal: true
module Orders
class WorkflowService
attr_reader :order
def initialize(order)
@order = order
end
def complete
advance_to_state("complete", advance_order_options)
end
def complete!
advance_order!(advance_order_options)
end
def next(options = {})
result = advance_order_one_step
after_transition_hook(options)
result
end
def advance_to_payment
return unless order.before_payment_state?
advance_to_state("payment", advance_order_options)
end
def advance_checkout(options = {})
advance_to = order.before_payment_state? ? "payment" : "confirmation"
advance_to_state(advance_to, advance_order_options.merge(options))
end
private
def advance_order_options
shipping_method_id = order.shipping_method.id if order.shipping_method.present?
{ "shipping_method_id" => shipping_method_id }
end
def advance_to_state(target_state, options = {})
until order.state == target_state
break unless order.next
after_transition_hook(options)
end
order.state == target_state
end
def advance_order!(options)
until order.completed?
order.next!
after_transition_hook(options)
end
end
def advance_order_one_step
tries ||= 3
order.next
rescue ActiveRecord::StaleObjectError
retry unless (tries -= 1).zero?
false
end
def after_transition_hook(options)
if order.state == "delivery"
order.select_shipping_method(options["shipping_method_id"])
end
persist_all_payments if order.state == "payment"
end
# When a payment fails, the order state machine stays in 'payment'
# and rollbacks all transactions
# This rollback also reverts the payment state from 'failed', 'void' or 'invalid' to 'pending'
# Despite the rollback, the in-memory payment still has the correct state, so we persist it
def persist_all_payments
order.payments.each do |payment|
in_memory_payment_state = payment.state
if different_from_db_payment_state?(in_memory_payment_state, payment.id)
payment.reload.update(state: in_memory_payment_state)
end
end
end
# Verifies if the in-memory payment state is different from the one stored in the database
# This is be done without reloading the payment so that in-memory data is not changed
def different_from_db_payment_state?(in_memory_payment_state, payment_id)
in_memory_payment_state != Spree::Payment.find(payment_id).state
end
end
end

View File

@@ -1,24 +0,0 @@
# frozen_string_literal: true
class OrdersBulkCancelService
def initialize(params, current_user)
@order_ids = params[:bulk_ids]
@current_user = current_user
@send_cancellation_email = params[:send_cancellation_email]
@restock_items = params[:restock_items]
end
def call
editable_orders.where(id: @order_ids).each do |order|
order.send_cancellation_email = @send_cancellation_email
order.restock_items = @restock_items
order.cancel
end
end
private
def editable_orders
Permissions::Order.new(@current_user).editable_orders
end
end

View File

@@ -87,7 +87,7 @@ class PlaceProxyOrder
end
def move_to_completion
OrderWorkflow.new(order).complete!
Orders::WorkflowService.new(order).complete!
end
def send_placement_email

View File

@@ -58,7 +58,7 @@ class ProcessPaymentIntent
def process_payment
return unless order.process_payments!
OrderWorkflow.new(order).complete
Orders::WorkflowService.new(order).complete
end
def ready_for_capture?

View File

@@ -64,7 +64,7 @@ class ProductsRenderer
end
def distributed_products
OrderCycleDistributedProducts.new(distributor, order_cycle, customer)
OrderCycles::DistributedProductsService.new(distributor, order_cycle, customer)
end
def products_order

View File

@@ -12,8 +12,8 @@ module Shop
def self.ready_for_checkout_for(distributor, customer)
new(distributor, customer).call.select do |order_cycle|
order = Spree::Order.new(distributor:, order_cycle:)
OrderAvailablePaymentMethods.new(order, customer).to_a.any? &&
OrderAvailableShippingMethods.new(order, customer).to_a.any?
Orders::AvailablePaymentMethodsService.new(order, customer).to_a.any? &&
Orders::AvailableShippingMethodsService.new(order, customer).to_a.any?
end
end

View File

@@ -12,7 +12,7 @@ module OrderManagement
def initialize(order, payment: nil, off_session: false, notify_hub: false)
@order = order
@payment = payment || OrderPaymentFinder.new(order).last_pending_payment
@payment = payment || Orders::FindPaymentService.new(order).last_pending_payment
@off_session = off_session
@notify_hub = notify_hub
end

View File

@@ -15,7 +15,7 @@ module OrderManagement
@options = options
@estimator = OrderManagement::Subscriptions::Estimator.new(subscription)
@validator = OrderManagement::Subscriptions::Validator.new(subscription)
@order_syncer = OrderSyncer.new(subscription)
@order_syncer = Orders::SyncService.new(subscription)
end
def save

View File

@@ -18,7 +18,7 @@ module OrderManagement
private
def create_payment
payment = OrderPaymentFinder.new(@order).last_pending_payment
payment = Orders::FindPaymentService.new(@order).last_pending_payment
return payment if payment.present?
@order.payments.create(

View File

@@ -14,7 +14,7 @@ module OrderManagement
when ActiveRecord::Relation
@subscriptions = subscriptions.not_ended.not_canceled
else
raise "ProxyOrderSyncer must be initialized with " \
raise "ProxyOrders::SyncService must be initialized with " \
"an instance of Subscription or ActiveRecord::Relation"
end
end

View File

@@ -5,7 +5,7 @@ module OrderManagement
class StripePaymentSetup
def initialize(order)
@order = order
@payment = OrderPaymentFinder.new(@order).last_pending_payment
@payment = Orders::FindPaymentService.new(@order).last_pending_payment
end
def call!

View File

@@ -36,7 +36,7 @@ module Reporting
without_editable_line_items = line_items - editable_line_items(line_items)
without_editable_line_items.each do |line_item|
OrderDataMasker.new(line_item.order).call
Orders::MaskDataService.new(line_item.order).call
end
line_items

View File

@@ -43,7 +43,7 @@ module Reporting
editable_orders_ids = permissions.editable_orders.select(&:id).map(&:id)
orders
.filter { |order| order.in?(editable_orders_ids) }
.each { |order| OrderDataMasker.new(order).call }
.each { |order| Orders::MaskDataService.new(order).call }
# Get Line Items
orders.map(&:line_items).flatten
end

View File

@@ -11,7 +11,7 @@ module Reporting
total_excl_vat: proc { |order| order.total - order.total_tax }
}
add_key_for_each_rate(result, proc { |rate|
proc { |order| OrderTaxAdjustmentsFetcher.new(order).totals.fetch(rate, 0) }
proc { |order| Orders::FetchTaxAdjustmentsService.new(order).totals.fetch(rate, 0) }
})
other = {
total_tax: proc { |order| order.total_tax },

View File

@@ -51,7 +51,7 @@ module SampleData
def create_complete_order
order = create_cart_order
OrderWorkflow.new(order).complete
Orders::WorkflowService.new(order).complete
order
end

View File

@@ -390,7 +390,7 @@ describe Admin::BulkLineItemsController, type: :controller do
order.shipments.map(&:refresh_rates)
order.select_shipping_method(shipping_method.id)
OrderWorkflow.new(order).advance_to_payment
Orders::WorkflowService.new(order).advance_to_payment
order.finalize!
order.recreate_all_fees!
order.create_tax_charge!

View File

@@ -160,12 +160,12 @@ module Admin
let(:shop) { create(:distributor_enterprise) }
context "as a manager of a shop" do
let(:form_mock) { instance_double(OrderCycleForm) }
let(:form_mock) { instance_double(OrderCycles::FormService) }
let(:params) { { as: :json, order_cycle: {} } }
before do
controller_login_as_enterprise_user([shop])
allow(OrderCycleForm).to receive(:new) { form_mock }
allow(OrderCycles::FormService).to receive(:new) { form_mock }
end
context "when creation is successful" do
@@ -203,10 +203,10 @@ module Admin
describe "update" do
let(:order_cycle) { create(:simple_order_cycle) }
let(:coordinator) { order_cycle.coordinator }
let(:form_mock) { instance_double(OrderCycleForm) }
let(:form_mock) { instance_double(OrderCycles::FormService) }
before do
allow(OrderCycleForm).to receive(:new) { form_mock }
allow(OrderCycles::FormService).to receive(:new) { form_mock }
end
context "as a manager of the coordinator" do
@@ -275,7 +275,7 @@ module Admin
end
it "can update preference product_selection_from_coordinator_inventory_only" do
expect(OrderCycleForm).to receive(:new).
expect(OrderCycles::FormService).to receive(:new).
with(order_cycle,
{ "preferred_product_selection_from_coordinator_inventory_only" => true },
anything) { form_mock }
@@ -288,7 +288,7 @@ module Admin
end
it "can update preference automatic_notifications" do
expect(OrderCycleForm).to receive(:new).
expect(OrderCycles::FormService).to receive(:new).
with(order_cycle,
{ "automatic_notifications" => true },
anything) { form_mock }
@@ -324,7 +324,7 @@ module Admin
format: :json, id: order_cycle.id, order_cycle: allowed.merge(restricted)
}
}
let(:form_mock) { instance_double(OrderCycleForm, save: true) }
let(:form_mock) { instance_double(OrderCycles::FormService, save: true) }
before { allow(controller).to receive(:spree_current_user) { user } }
@@ -333,7 +333,7 @@ module Admin
let(:expected) { [order_cycle, allowed.merge(restricted), user] }
it "allows me to update exchange information for exchanges, name and dates" do
expect(OrderCycleForm).to receive(:new).with(*expected) { form_mock }
expect(OrderCycles::FormService).to receive(:new).with(*expected) { form_mock }
spree_put :update, params
end
end
@@ -343,7 +343,7 @@ module Admin
let(:expected) { [order_cycle, allowed, user] }
it "allows me to update exchange information for exchanges, but not name or dates" do
expect(OrderCycleForm).to receive(:new).with(*expected) { form_mock }
expect(OrderCycles::FormService).to receive(:new).with(*expected) { form_mock }
spree_put :update, params
end
end

View File

@@ -83,7 +83,7 @@ describe Admin::ProxyOrdersController, type: :controller do
before do
# Processing order to completion
allow(Spree::OrderMailer).to receive(:cancel_email) { double(:email, deliver_later: true) }
OrderWorkflow.new(order).complete!
Orders::WorkflowService.new(order).complete!
proxy_order.reload
proxy_order.cancel
allow(controller).to receive(:spree_current_user) { user }

View File

@@ -256,7 +256,7 @@ describe CheckoutController, type: :controller do
order.bill_address = address
order.ship_address = address
order.select_shipping_method shipping_method.id
OrderWorkflow.new(order).advance_to_payment
Orders::WorkflowService.new(order).advance_to_payment
end
context "with incomplete data" do
@@ -387,7 +387,7 @@ describe CheckoutController, type: :controller do
order.bill_address = address
order.ship_address = address
order.select_shipping_method shipping_method.id
OrderWorkflow.new(order).advance_to_payment
Orders::WorkflowService.new(order).advance_to_payment
order.payments << build(:payment, amount: order.total, payment_method:)
order.next
@@ -459,7 +459,7 @@ describe CheckoutController, type: :controller do
allow(order).to receive(:distributor).and_return(distributor)
order.update(order_cycle:)
allow(OrderCycleDistributedVariants).to receive(:new).and_return(
allow(OrderCycles::DistributedVariantsService).to receive(:new).and_return(
order_cycle_distributed_variants
)
end

View File

@@ -11,7 +11,9 @@ module PaymentGateways
let!(:order) { create(:order_with_totals, distributor:, order_cycle:) }
let(:exchange) { order_cycle.exchanges.to_enterprises(distributor).outgoing.first }
let(:order_cycle_distributed_variants) { instance_double(OrderCycleDistributedVariants) }
let(:order_cycle_distributed_variants) {
instance_double(OrderCycles::DistributedVariantsService)
}
before do
exchange.variants << order.line_items.first.variant
@@ -305,11 +307,11 @@ completed due to stock issues."
context "with an invalid last payment" do
let(:payment_intent) { "valid" }
let(:finder) { instance_double(OrderPaymentFinder, last_payment: payment) }
let(:finder) { instance_double(Orders::FindPaymentService, last_payment: payment) }
before do
allow(payment).to receive(:response_code).and_return("invalid")
allow(OrderPaymentFinder).to receive(:new).with(order).and_return(finder)
allow(Orders::FindPaymentService).to receive(:new).with(order).and_return(finder)
allow(Stripe::PaymentIntentValidator)
.to receive_message_chain(:new, :call).and_return(payment_intent)
stub_payment_intent_get_request(payment_intent_id: "valid")

View File

@@ -28,7 +28,7 @@ FactoryBot.define do
after(:create) do |order, evaluator|
order.select_shipping_method evaluator.shipping_method.id
OrderWorkflow.new(order).advance_to_payment
Orders::WorkflowService.new(order).advance_to_payment
end
factory :order_ready_for_confirmation do

Some files were not shown because too many files have changed in this diff Show More