Developing Custom Extensions for Spree Commerce
Spree extends through Rails Engine — a separate gem with its own models, controllers, views, and migrations. The mechanism is the same as Spree itself: Rails Engine + Decorator pattern to change behavior of existing classes. The extension can be packaged as a gem for reuse or kept directly in the main application.
Two Approaches to Extension
Inline (in application): Decorators in app/models/spree/, view overrides through deface, new controllers. Suitable for specific customizations.
Rails Engine (gem): A full gem with Engine mounted in the main application. Suitable for reusable functionality — for example, a plugin for a specific payment provider.
Extension Structure as Engine
bundle gem spree_b2b_pricing --no-ext
spree_b2b_pricing/
├── app/
│ ├── models/
│ │ └── spree/
│ │ ├── b2b_price_list.rb
│ │ └── order_decorator.rb
│ ├── controllers/
│ │ └── spree/
│ │ └── api/v2/
│ │ └── storefront/
│ │ └── price_lists_controller.rb
│ └── views/
│ └── spree/
│ └── admin/
├── config/
│ ├── routes.rb
│ └── initializers/
│ └── spree_b2b_pricing.rb
├── db/
│ └── migrate/
│ └── 20240101000000_create_spree_b2b_price_lists.rb
├── lib/
│ ├── spree_b2b_pricing.rb
│ └── spree_b2b_pricing/
│ ├── engine.rb
│ └── version.rb
└── spree_b2b_pricing.gemspec
Engine
# lib/spree_b2b_pricing/engine.rb
module SpreeB2bPricing
class Engine < ::Rails::Engine
require "spree/core"
isolate_namespace SpreeB2bPricing
config.autoload_paths += %W[#{config.root}/lib]
initializer "spree_b2b_pricing.register_hooks" do
Spree::Config.configure do |config|
# Register custom price calculator
end
end
def self.activate
Dir.glob(File.join(File.dirname(__FILE__), "../../app/**/*_decorator.rb")) do |c|
Rails.configuration.cache_classes ? require(c) : load(c)
end
end
config.to_prepare(&method(:activate))
end
end
Decorator: Extending Spree::Order Model
# app/models/spree/order_decorator.rb
module Spree
module OrderDecorator
def self.prepended(base)
base.belongs_to :b2b_price_list,
class_name: "Spree::B2bPriceList",
optional: true
end
# Override price recalculation method
def update_line_item_prices!
return super unless b2b_price_list
line_items.each do |line_item|
b2b_price = b2b_price_list.price_for(
line_item.variant,
currency
)
if b2b_price
line_item.price = b2b_price
line_item.save!
end
end
super
end
def applicable_promotions
return super unless user&.b2b_customer?
# B2B customers don't get public promo codes
super.where(b2b_only: true)
end
end
end
Spree::Order.prepend(Spree::OrderDecorator)
Overriding Views with Deface
# app/overrides/spree/admin/products/_form_override.rb
Deface::Override.new(
virtual_path: "spree/admin/products/_form",
name: "add_b2b_wholesale_price",
insert_after: "[data-hook='product_form_right']",
text: <<~HTML
<div data-hook="b2b_wholesale_price">
<%= f.field_container :wholesale_price do %>
<%= f.label :wholesale_price, "Wholesale Price (USD)" %>
<%= f.text_field :wholesale_price, class: "form-control" %>
<% end %>
</div>
HTML
)
Deface doesn't modify Spree files — it applies patches at runtime, making Spree updates safe.
Extending Admin API
# app/controllers/spree/admin/b2b_price_lists_controller.rb
module Spree
module Admin
class B2bPriceListsController < Spree::Admin::ResourceController
before_action :load_resource
def create
@b2b_price_list = Spree::B2bPriceList.new(b2b_price_list_params)
if @b2b_price_list.save
flash[:success] = "Price list created"
redirect_to admin_b2b_price_lists_path
else
render :new
end
end
private
def b2b_price_list_params
params.require(:b2b_price_list).permit(:name, :discount_percent, :active)
end
end
end
end
Extending Storefront API v2
# app/controllers/spree/api/v2/storefront/b2b_controller.rb
module Spree
module Api
module V2
module Storefront
class B2bController < ::Spree::Api::V2::BaseController
before_action :require_spree_current_user
def price_list
user = spree_current_user
price_list = Spree::B2bPriceList.for_user(user)
render_serialized_payload { serialize_resource(price_list) }
end
end
end
end
end
end
# config/routes.rb
Spree::Core::Engine.routes.draw do
namespace :api do
namespace :v2 do
namespace :storefront do
resource :b2b, only: [:show] do
get :price_list
end
end
end
end
namespace :admin do
resources :b2b_price_lists
end
end
Migration
# db/migrate/20240101000000_create_spree_b2b_price_lists.rb
class CreateSpreeB2bPriceLists < ActiveRecord::Migration[7.1]
def change
create_table :spree_b2b_price_lists do |t|
t.string :name, null: false
t.decimal :discount_percent, precision: 5, scale: 2
t.boolean :active, default: true, null: false
t.timestamps
end
add_column :spree_orders, :b2b_price_list_id, :bigint
add_foreign_key :spree_orders, :spree_b2b_price_lists,
column: :b2b_price_list_id
add_index :spree_orders, :b2b_price_list_id
end
end
Extension Testing
# spec/models/spree/order_decorator_spec.rb
RSpec.describe Spree::Order do
describe "#update_line_item_prices!" do
let(:price_list) { create(:b2b_price_list, discount_percent: 10) }
let(:order) { create(:order_with_line_items, b2b_price_list: price_list) }
it "applies b2b pricing to line items" do
original_price = order.line_items.first.price
order.update_line_item_prices!
expect(order.line_items.first.reload.price)
.to be_within(0.01).of(original_price * 0.9)
end
end
end
Typical Tasks and Timelines
| Extension | Timeline |
|---|---|
| Custom shipping calculator | 1–2 days |
| B2B pricing | 3–5 days |
| Loyalty program | 4–6 days |
| Custom payment gateway | 2–4 days |
| Admin UI extension (new sections) | 2–3 days |







