Merge pull request #8032 from Matt-Yorkley/order-confirm

Split checkout backend
This commit is contained in:
Matt-Yorkley
2021-08-16 23:12:06 +02:00
committed by GitHub
12 changed files with 193 additions and 237 deletions

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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