🚀 See the 2024 Ruby on Rails Community Survey results!
Article  |  Development

Creating Custom Actions in Spree Commerce

Reading time: ~ 3 minutes

spree-custom-actions

Recently a client of ours came to us with a proposition of adding some discounts on their e-commerce store for customers that met certain conditions. They needed this implemented before the holiday season. The application in question is built on top of Spree Commerce so the path of least resistance was to expand on Spree's promotion class. Spree's promotion rules/actions section of the engine is one of the more complicated interactions that is handled in the platform but it is easily modified to fit most applications’ needs.

While adding promotion rules is well documented, I struggled with finding anything about the more complicated promotion action part of the equation. Google turned up some old posts based in Spree 1.X but nothing for spree 2+. Here I will outline the steps I took to get this up and running for Spree 2.4.

Create the custom action

This class will handle actually applying the adjustments we want to make on orders. By following Spree's guidelines for extending promotions, we know to create our new action at: app/models/spree/promotion_handler/discount_shipping.rb

module Spree
  class Promotion
    module Actions
      class DiscountShipping < PromotionAction
        include Spree::CalculatedAdjustments

        before_validation :ensure_action_has_calculator

        def perform(payload={})
          order = payload[:order]
          results = order.shipments.map do |shipment|
            return false if promotion_credit_exists?(shipment)
            shipment.adjustments.create!(
              order: shipment.order,
              amount: compute_amount(shipment),
              source: self,
              label: label,
            )
            true
          end
          results.any? { |r| r == true }
        end

        def label
          "#{Spree.t(:promotion)} (#{promotion.name})"
        end

        def compute_amount(shipment)
          amount = self.calculator.compute(shipment).to_f.abs
          [shipment.cost, amount].min * -1
        end

        private

        def promotion_credit_exists?(shipment)
          shipment.adjustments.where(:source_id => self.id).exists?
        end

        def ensure_action_has_calculator
          return if self.calculator
          self.calculator = Spree::Calculator::FlatPercentItemTotal.new
        end
      end
    end
  end
end

Getting things working in the CMS

Spree needs to know about the new action that we just created. Add this line to your spree.rb file located at: config/initializers/spree.rb

Rails.application.config.spree.promotions.actions << Spree::Promotion::Actions::DiscountShipping

Now we need to give Spree some text to render when we choose our new action in the CMS. Alter your en.yml file to look something like:

en:
  spree:
    promotion_action_types:
      discount_shipping:
        name: "Discount Shipping"
        description: "Add a discounted shipping rate"

The last step is to create a partial for spree to use in the CMS. For this action, add a partial at: app/views/spree/admin/promotions/actions/_discount_shipping.html.erb

<div class="calculator-fields row">

  <div class="field alpha four columns">
    <% field_name = "#{param_prefix}[calculator_type]" %>
    <%= label_tag field_name, Spree.t(:calculator) %>
    <%= select_tag field_name,
                   options_from_collection_for_select(@calculators, :to_s, :description, promotion_action.calculator.type),
                   :class => 'type-select select2 fullwidth' %>
    <% if promotion_action.calculator.respond_to?(:preferences) %>
      <span class="warning info"><%= Spree.t(:calculator_settings_warning) %></span>
    <% end %>
  </div>

  <% unless promotion_action.new_record? %>
    <div class="settings field omega four columns">
      <% type_name = promotion_action.calculator.type.demodulize.underscore %>
      <% if lookup_context.exists?("fields",
          ["spree/admin/promotions/calculators/#{type_name}"], true) %>
        <%= render "spree/admin/promotions/calculators/#{type_name}/fields",
          calculator: promotion_action.calculator, prefix: param_prefix %>
      <% else %>
        <%= render "spree/admin/promotions/calculators/default_fields",
          calculator: promotion_action.calculator, prefix: param_prefix %>
      <% end %>
      <%= hidden_field_tag "#{param_prefix}[calculator_attributes][id]", promotion_action.calculator.id %>
    </div>
  <% end %>
</div>

This will give you access to all Spree's built-in calculators. If your new action requires no user input then this can simply be a blank file.

Now we should be able to successfully add our new action to any promotion! But we aren't done yet.

Tying it all together

We need to instruct Spree when to actually apply our promotion. For this example, a shipment isn't generated until a user is in a certain part of the checkout process. We want to apply our promotion earlier in the process, when a user transitions to the delivery checkout step, so a shipment record exists for our promotion.

Create a file at: app/models/spree/promotion_handler/discount_shipping.rb

module Spree
  module PromotionHandler
    class DiscountShipping
      attr_reader :order, :order_promo_ids
      attr_accessor :error, :success

      def initialize(order)
        @order = order
        @order_promo_ids = order.promotions.pluck(:id)
      end

      def activate
        promotions.each do |promotion|
          next if promotion.code.present? && !order_promo_ids.include?(promotion.id)

          if promotion.eligible?(order)
            promotion.activate(order: order)
          end
        end
      end

      private

      def promotions
        Spree::Promotion.active.where(
          id: Spree::Promotion::Actions::DiscountShipping.pluck(:promotion_id),
          path: nil
        )
      end
    end
  end
end

The final step is telling Spree where in the order process to apply our action. If you don't have an extension class set up for the order model, go ahead and create that now: app/models/spree/order_decorator.rb

Spree::Order.class_eval do
  state_machine.before_transition to: :delivery do |order|
    order.apply_shipping_discounts
  end

  def apply_shipping_discounts
    Spree::PromotionHandler::DiscountShipping.new(self).activate
    shipments.each { |shipment| Spree::ItemAdjustments.new(shipment).update }
    updater.update_shipment_total
    persist_totals
  end
end

So now when a user is eligible for our promotion with our new action, Spree will create an adjustment on the order's shipments based on the action's calculator and the values that were input in the CMS for the calculator to use.

That's a Wrap!

We now have a functioning new action that will apply to eligible orders on the delivery step.

Happy coding!

This was built with: - Spree 2.4.9 - Ruby 2.2.2 - Rails 4.1.11

Have a project that needs help?