mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-02-01 21:47:16 +00:00
Merge pull request #8032 from Matt-Yorkley/order-confirm
Split checkout backend
This commit is contained in:
86
app/controllers/concerns/checkout_callbacks.rb
Normal file
86
app/controllers/concerns/checkout_callbacks.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user