diff --git a/app/controllers/concerns/checkout_callbacks.rb b/app/controllers/concerns/checkout_callbacks.rb new file mode 100644 index 0000000000..08a063ee89 --- /dev/null +++ b/app/controllers/concerns/checkout_callbacks.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module CheckoutCallbacks + extend ActiveSupport::Concern + + included do + # We need pessimistic locking to avoid race conditions. + # Otherwise we fail on duplicate indexes or end up with negative stock. + prepend_around_action CurrentOrderLocker, only: [:edit, :update] + + prepend_before_action :check_hub_ready_for_checkout + prepend_before_action :check_order_cycle_expiry + prepend_before_action :require_order_cycle + prepend_before_action :require_distributor_chosen + + before_action :load_order, :associate_user, :load_saved_addresses + before_action :load_shipping_methods, :load_countries, if: -> { checkout_step == "details"} + + before_action :ensure_order_not_completed + before_action :ensure_checkout_allowed + before_action :handle_insufficient_stock + before_action :check_authorization + before_action :enable_embedded_shopfront + end + + private + + def load_order + @order = current_order + + redirect_to(main_app.shop_path) && return if redirect_to_shop? + redirect_to_cart_path && return unless valid_order_line_items? + end + + def load_saved_addresses + finder = OpenFoodNetwork::AddressFinder.new(@order.email, @order.customer, spree_current_user) + + @order.bill_address = finder.bill_address + @order.ship_address = finder.ship_address + end + + def load_shipping_methods + @shipping_methods = Spree::ShippingMethod.for_distributor(@order.distributor).order(:name) + end + + def load_countries + @countries = available_countries.map { |c| [c.name, c.id] } + @countries_with_states = available_countries.map { |c| [c.id, c.states.map { |s| [s.name, s.id] }] } + end + + def redirect_to_shop? + !@order || + !@order.checkout_allowed? || + @order.completed? + end + + def redirect_to_cart_path + respond_to do |format| + format.html do + redirect_to main_app.cart_path + end + + format.json do + render json: { path: main_app.cart_path }, status: :bad_request + end + end + end + + def valid_order_line_items? + @order.insufficient_stock_lines.empty? && + OrderCycleDistributedVariants.new(@order.order_cycle, @order.distributor). + distributes_order_variants?(@order) + end + + def ensure_order_not_completed + redirect_to main_app.cart_path if @order.completed? + end + + def ensure_checkout_allowed + redirect_to main_app.cart_path unless @order.checkout_allowed? + end + + def check_authorization + authorize!(:edit, current_order, session[:access_token]) + end +end diff --git a/app/controllers/split_checkout_controller.rb b/app/controllers/split_checkout_controller.rb index 47b8728a76..dfc979d46a 100644 --- a/app/controllers/split_checkout_controller.rb +++ b/app/controllers/split_checkout_controller.rb @@ -7,37 +7,19 @@ class SplitCheckoutController < ::BaseController include OrderStockCheck include Spree::BaseHelper + include CheckoutCallbacks helper 'terms_and_conditions' helper 'checkout' - - # We need pessimistic locking to avoid race conditions. - # Otherwise we fail on duplicate indexes or end up with negative stock. - prepend_around_action CurrentOrderLocker, only: [:edit, :update] - - prepend_before_action :set_checkout_step - prepend_before_action :check_hub_ready_for_checkout - prepend_before_action :check_order_cycle_expiry - prepend_before_action :require_order_cycle - prepend_before_action :require_distributor_chosen - - before_action :load_order, :load_shipping_methods, :load_countries - - before_action :ensure_order_not_completed - before_action :ensure_checkout_allowed - before_action :handle_insufficient_stock - - before_action :associate_user - before_action :check_authorization - before_action :enable_embedded_shopfront - helper 'spree/orders' helper OrderHelper def edit return handle_redirect_from_stripe if valid_payment_intent_provided? - redirect_to_step unless @checkout_step + redirect_to_step unless checkout_step + + OrderWorkflow.new(@order).next if @order.cart? # This is only required because of spree_paypal_express. If we implement # a version of paypal that uses this controller, and more specifically @@ -48,129 +30,77 @@ class SplitCheckoutController < ::BaseController end def update - if @order.update(order_params) - OrderWorkflow.new(@order).advance_to_payment - - if @order.state == "payment" - redirect_to checkout_step_path(:payment) - else - render :edit - end + if confirm_order || update_order + clear_invalid_payments + advance_order_state + redirect_to_step else - flash[:error] = "Saving failed, please update the highlighted fields" - + flash.now[:error] = "Saving failed, please update the highlighted fields" render :edit end end - # Clears the cached order. Required for #current_order to return a new order - # to serve as cart. See https://github.com/spree/spree/blob/1-3-stable/core/lib/spree/core/controller_helpers/order.rb#L14 - # for details. - def expire_current_order - session[:order_id] = nil - @current_order = nil - end - private - def set_checkout_step - @checkout_step = params[:step] + def clear_invalid_payments + @order.payments.with_state(:invalid).delete_all + end + + def confirm_order + return unless @order.confirmation? && params[:confirm_order] + + @order.confirm! + end + + def update_order + return unless params[:order] + + @order.update(order_params) + end + + def advance_order_state + return if @order.complete? + + workflow_options = raw_params.slice(:shipping_method_id) + + OrderWorkflow.new(@order).advance_to_confirmation(workflow_options) + end + + def checkout_step + @checkout_step ||= params[:step] end def order_params - params.require(:order).permit( + return @order_params unless @order_params.nil? + + @order_params = params.require(:order).permit( :email, :shipping_method_id, :special_instructions, bill_address_attributes: PermittedAttributes::Address.attributes, ship_address_attributes: PermittedAttributes::Address.attributes, payments_attributes: [:payment_method_id] ) + + if @order_params[:payments_attributes] + # Set payment amount + @order_params[:payments_attributes].first[:amount] = @order.total + end + + @order_params end def redirect_to_step - if @order.state == "payment" - if true# order.has_no_payment_method_chosen? - redirect_to checkout_step_path(:payment) - else - redirect_to checkout_step_path(:summary) - end - else + case @order.state + when "cart", "address", "delivery" redirect_to checkout_step_path(:details) + when "payment" + redirect_to checkout_step_path(:payment) + when "confirmation" + redirect_to checkout_step_path(:summary) + else + redirect_to order_path(@order) end end - def check_authorization - authorize!(:edit, current_order, session[:access_token]) - end - - def ensure_checkout_allowed - redirect_to main_app.cart_path unless @order.checkout_allowed? - end - - def ensure_order_not_completed - redirect_to main_app.cart_path if @order.completed? - end - - def load_shipping_methods - @shipping_methods = Spree::ShippingMethod.for_distributor(@order.distributor).order(:name) - end - - def load_countries - @countries = available_countries.map { |c| [c.name, c.id] } - @countries_with_states = available_countries.map { |c| [c.id, c.states.map { |s| [s.name, s.id] }] } - end - - def load_order - @order = current_order - - redirect_to(main_app.shop_path) && return if redirect_to_shop? - redirect_to_cart_path && return unless valid_order_line_items? - - before_address - setup_for_current_state - end - - def redirect_to_shop? - !@order || - !@order.checkout_allowed? || - @order.completed? - end - - def valid_order_line_items? - @order.insufficient_stock_lines.empty? && - OrderCycleDistributedVariants.new(@order.order_cycle, @order.distributor). - distributes_order_variants?(@order) - end - - def redirect_to_cart_path - respond_to do |format| - format.html do - redirect_to main_app.cart_path - end - - format.json do - render json: { path: main_app.cart_path }, status: :bad_request - end - end - end - - def setup_for_current_state - method_name = :"before_#{@order.state}" - __send__(method_name) if respond_to?(method_name, true) - end - - def before_address - associate_user - - finder = OpenFoodNetwork::AddressFinder.new(@order.email, @order.customer, spree_current_user) - - @order.bill_address = finder.bill_address - @order.ship_address = finder.ship_address - end - - def before_payment - current_order.payments.destroy_all if request.put? - end - def valid_payment_intent_provided? return false unless params["payment_intent"]&.starts_with?("pi_") @@ -191,107 +121,12 @@ class SplitCheckoutController < ::BaseController end end - def checkout_workflow(shipping_method_id) - while @order.state != "complete" - if @order.state == "payment" - return if redirect_to_payment_gateway - - return action_failed unless @order.process_payments! - end - - next if OrderWorkflow.new(@order).next({ shipping_method_id: shipping_method_id }) - - return action_failed - end - - update_response - end - - def redirect_to_payment_gateway - return unless params&.dig(:order)&.dig(:payments_attributes)&.first&.dig(:payments_attributes) - - redirect_path = Checkout::PaypalRedirect.new(params).path - redirect_path = Checkout::StripeRedirect.new(params, @order).path if redirect_path.blank? - return if redirect_path.blank? - - render json: { path: redirect_path }, status: :ok - true - end - - def order_error - if @order.errors.present? - @order.errors.full_messages.to_sentence - else - t(:payment_processing_failed) - end - end - - def update_response - if order_complete? - checkout_succeeded - update_succeeded_response - else - action_failed(RuntimeError.new("Order not complete after the checkout workflow")) - end - end - def order_complete? @order.state == "complete" || @order.completed? end - def checkout_succeeded - Checkout::PostCheckoutActions.new(@order).success(self, params, spree_current_user) - - session[:access_token] = current_order.token - flash[:notice] = t(:order_processed_successfully) - end - - def update_succeeded_response - respond_to do |format| - format.html do - respond_with(@order, location: order_path(@order)) - end - format.json do - render json: { path: order_path(@order) }, status: :ok - end - end - end - - def action_failed(error = RuntimeError.new(order_error)) - checkout_failed(error) - action_failed_response - end - - def checkout_failed(error = RuntimeError.new(order_error)) - Bugsnag.notify(error) - Checkout::PostCheckoutActions.new(@order).failure - end - - def action_failed_response - respond_to do |format| - format.html do - render :edit - end - format.json do - discard_flash_errors - render json: { errors: @order.errors, flash: flash.to_hash }.to_json, status: :bad_request - end - end - end - def rescue_from_spree_gateway_error(error) flash[:error] = t(:spree_gateway_error_flash_for_checkout, error: error.message) action_failed(error) end - - def permitted_params - PermittedAttributes::Checkout.new(params).call - end - - def discard_flash_errors - # Marks flash errors for deletion after the current action has completed. - # This ensures flash errors generated during XHR requests are not persisted in the - # session for longer than expected. - flash.discard(:error) - end end diff --git a/app/helpers/checkout_helper.rb b/app/helpers/checkout_helper.rb index 02bc03e152..1285ce297c 100644 --- a/app/helpers/checkout_helper.rb +++ b/app/helpers/checkout_helper.rb @@ -122,4 +122,21 @@ module CheckoutHelper "{{ #{price} | localizeCurrency }}" end end + + def payment_or_shipping_price(method, order) + price = method.compute_amount(order) + if price.zero? + t('checkout_method_free') + else + Spree::Money.new(price, currency: order.currency) + end + end + + def checkout_step + params[:step] + end + + def checkout_step?(step) + checkout_step == step.to_s + end end diff --git a/app/models/spree/order.rb b/app/models/spree/order.rb index 90811c2eec..ca5b45dcfa 100644 --- a/app/models/spree/order.rb +++ b/app/models/spree/order.rb @@ -20,6 +20,9 @@ module Spree order.update_totals order.payment_required? } + go_to_state :confirmation, if: ->(order) { + Flipper.enabled? :split_checkout, order.user + } go_to_state :complete end @@ -78,20 +81,20 @@ module Spree before_validation :associate_customer, unless: :customer_id? before_validation :ensure_customer, unless: :customer_is_valid? - validates :customer, presence: true, if: :require_customer? - validate :products_available_from_new_distribution, if: lambda { - distributor_id_changed? || order_cycle_id_changed? - } - validate :disallow_guest_order - attr_accessor :use_billing before_create :link_by_email after_create :create_tax_charge! + validates :customer, presence: true, if: :require_customer? + validate :products_available_from_new_distribution, if: lambda { + distributor_id_changed? || order_cycle_id_changed? + } + validate :disallow_guest_order validates :email, presence: true, format: /\A([\w.%+\-']+)@([\w\-]+\.)+(\w{2,})\z/i, if: :require_email + validates :payments, presence: true, if: ->(order) { order.confirmation? && payment_required? } make_permalink field: :number diff --git a/app/models/spree/order/checkout.rb b/app/models/spree/order/checkout.rb index 9a8a0511c9..148348ce1e 100644 --- a/app/models/spree/order/checkout.rb +++ b/app/models/spree/order/checkout.rb @@ -67,6 +67,10 @@ module Spree transition to: :cart, unless: :completed? end + event :confirm do + transition to: :complete, from: :confirmation + end + before_transition from: :cart, do: :ensure_line_items_present before_transition to: :delivery, do: :create_proposed_shipments diff --git a/app/services/order_workflow.rb b/app/services/order_workflow.rb index e0b0bb9d1e..39571a6545 100644 --- a/app/services/order_workflow.rb +++ b/app/services/order_workflow.rb @@ -27,6 +27,14 @@ class OrderWorkflow advance_to_state("payment", advance_order_options) end + def advance_to_confirmation(options = {}) + if options[:shipping_method_id] + order.select_shipping_method(options[:shipping_method_id]) + end + + advance_to_state("confirmation") + end + private def advance_order_options @@ -34,12 +42,14 @@ class OrderWorkflow { shipping_method_id: shipping_method_id } end - def advance_to_state(target_state, options) + 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) diff --git a/app/views/split_checkout/_details.html.haml b/app/views/split_checkout/_details.html.haml index 121cf6513a..c032f96917 100644 --- a/app/views/split_checkout/_details.html.haml +++ b/app/views/split_checkout/_details.html.haml @@ -80,7 +80,7 @@ "data-toggle-show": shipping_method.require_ship_address = shipping_method_form.label shipping_method.id, shipping_method.name, {for: "shipping_method_" + shipping_method.id.to_s } %em.light - = payment_method_price(shipping_method, @order) + = payment_or_shipping_price(shipping_method, @order) %div.checkout-input{ "data-toggle-target": "content", style: "display: none" } diff --git a/app/views/split_checkout/_form.html.haml b/app/views/split_checkout/_form.html.haml index 8aa242ae48..610fabdcf6 100644 --- a/app/views/split_checkout/_form.html.haml +++ b/app/views/split_checkout/_form.html.haml @@ -2,5 +2,5 @@ = inject_saved_credit_cards %div.checkout-step.medium-6 - = form_with url: checkout_update_path(@checkout_step), model: @order, method: :put do |f| - = render "split_checkout/#{@checkout_step}", f: f + = form_with url: checkout_update_path(checkout_step), model: @order, method: :put do |f| + = render "split_checkout/#{checkout_step}", f: f diff --git a/app/views/split_checkout/_payment.html.haml b/app/views/split_checkout/_payment.html.haml index d9c6b9d8a6..b90d17aec5 100644 --- a/app/views/split_checkout/_payment.html.haml +++ b/app/views/split_checkout/_payment.html.haml @@ -11,7 +11,7 @@ checked: (payment_method.id == selected_payment_method), "data-action": "paymentmethod#selectPaymentMethod", "data-paymentmethod-description": "#{payment_method.description}" - = f.label payment_method.id, "#{payment_method.name} (#{payment_method_price(payment_method, @order)})", {for: "payment_method_" + payment_method.id.to_s } + = f.label payment_method.id, "#{payment_method.name} (#{payment_or_shipping_price(payment_method, @order)})", {for: "payment_method_" + payment_method.id.to_s } %div .panel{"data-paymentmethod-target": "panel", style: "display: none"} diff --git a/app/views/split_checkout/_summary.html.haml b/app/views/split_checkout/_summary.html.haml index e22a27d720..320b9b1bf7 100644 --- a/app/views/split_checkout/_summary.html.haml +++ b/app/views/split_checkout/_summary.html.haml @@ -103,13 +103,13 @@ %div.checkout-substep %div.checkout-input - = f.check_box :accept_terms, {id: 'accept_terms', "checked": "#{all_terms_and_conditions_already_accepted?}"} + = f.check_box :accept_terms, {id: 'accept_terms', name: "accept_terms", "checked": "#{all_terms_and_conditions_already_accepted?}"} = f.label :accept_terms, t('split_checkout.step3.terms_and_conditions.message_html', terms_and_conditions_link: link_to( t("split_checkout.step3.terms_and_conditions.link_text"), @order.distributor.terms_and_conditions.url, target: '_blank'), tos_link: link_to_platform_terms), {for: "accept_terms"} %div.checkout-input = t("split_checkout.step3.agree") %div.checkout-submit - = f.submit t("split_checkout.step3.submit"), class: "button primary", disabled: @terms_and_conditions_accepted == false || @platform_tos_accepted == false + = f.submit t("split_checkout.step3.submit"), name: "confirm_order", class: "button primary", disabled: @terms_and_conditions_accepted == false || @platform_tos_accepted == false %a.button.cancel{href: main_app.cart_path} = t("split_checkout.step3.cancel") diff --git a/app/views/split_checkout/_tabs.html.haml b/app/views/split_checkout/_tabs.html.haml index 9f08b7522d..b285563784 100644 --- a/app/views/split_checkout/_tabs.html.haml +++ b/app/views/split_checkout/_tabs.html.haml @@ -1,12 +1,12 @@ %div.flex - %div.columns.three.text-center.checkout-tab{"class": [("selected" if @checkout_step == "details"), ("success" unless @checkout_step == "details")]} + %div.columns.three.text-center.checkout-tab{"class": [("selected" if checkout_step?(:details)), ("success" unless checkout_step?(:details))]} %span - = link_to_if (@checkout_step != "details"), t("split_checkout.your_details"), main_app.checkout_step_path(:details), {} do + = link_to_unless checkout_step?(:details), t("split_checkout.your_details"), main_app.checkout_step_path(:details) do = t("split_checkout.your_details") - %div.columns.three.text-center.checkout-tab{"class": [("selected" if @checkout_step == "payment"), ("success" if @checkout_step == "summary")]} + %div.columns.three.text-center.checkout-tab{"class": [("selected" if checkout_step?(:payment)), ("success" if checkout_step?(:summary))]} %span - = link_to_if (@checkout_step != "payment"), t("split_checkout.payment_method"), main_app.checkout_step_path(:payment), {} do + = link_to_if checkout_step?(:summary), t("split_checkout.payment_method"), main_app.checkout_step_path(:payment) do = t("split_checkout.payment_method") - %div.columns.three.text-center.checkout-tab{"class": ("selected" if @checkout_step == "summary")} + %div.columns.three.text-center.checkout-tab{"class": ("selected" if checkout_step?(:summary))} %span - = t("split_checkout.order_summary") + = t("split_checkout.order_summary") diff --git a/config/locales/en.yml b/config/locales/en.yml index f8f58c92da..4512f6f868 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3929,6 +3929,7 @@ See the %{link} to find out more about %{sitename}'s features and to start using awaiting_return: awaiting return canceled: cancelled cart: cart + confirmation: "confirmation" complete: complete confirm: confirm delivery: delivery