Solidus
Search…
Stock management

Architecture overview

The stock system in Solidus is possibly one of the most complicated and powerful provided by the platform. It consists of several pieces, all working in unison.
It all begins when an order is about to transition to the delivery state. When that happens, the order state machine calls the create_proposed_shipments method on the order, which in turn uses the configured stock coordinator to re-create the order's shipments.
If you remove the delivery state from the order state machine, or override the state machine with your own, the stock coordinator won't be called automatically anymore, and it will be up to you to call it at the right time.
The stock coordinator is the main actor in the stock system and coordinates all the other components. It takes an order as input and builds a list of proposed shipments for that order, along with their available shipping rates (e.g., FedEx Home Delivery for $10, FedEx Overnight for $20, etc.).
The default implementation of the stock coordinator is Spree::Stock::SimpleCoordinator, but you can use a different coordinator if you want. In the rest of this guide, we'll assume you're using the default SimpleCoordinator and we'll explain its inner workings.
The default SimpleCoordinator class contains stock coordination logic that is the result of years of eCommerce experience and community contributions. We strongly recommend going with the default implementation and only overriding its subcomponents, unless you really know what you're doing.
The work done by the stock estimator can be split in two logical chunks:
    1.
    First, the estimator creates the packages for the order. Packages are a simplified version of shipments, meant to hold information about which stock is leaving from which stock location.
    2.
    It then converts the packages into shipments and estimates the shipping rates for those shipments, depending on the shipping methods available in the store.
Let's see which other service objects are involved in these two processes!

Package creation

The following actors are involved in the package creation phase:
    Stock location filter: this class is responsible for filtering the stock locations created in the backend and only returning the ones that should be used for the current order (e.g., you may want to only use stock locations in the customer's country).
    Stock location sorter: this class is responsible for sorting the list of filtered stock locations in order of priority (e.g., you may want to pick inventory from the stock location closest to the customer).
    Stock allocator: this class is responsible for allocating stock from the selected stock locations (e.g., you may want to allocate on-hand inventory before backordering).
    Stock splitters: this class is responsible for splitting the allocated inventory units into different packages (e.g., you may want to keep all packages below a certain weight, and ship multiple packages if needed).
The process for package creation is fairly straightforward:
    1.
    First, the coordinator uses the configured stock location filter to get the list of stock locations to use for the current order.
    2.
    Then, the filtered list is sorted with the configured stock location sorter.
    3.
    Then, the filtered and sorted stock locations, along with the inventory units to allocate, are passed to the configured stock allocator, which maps each stock location to a list of on-hand inventory units to ship and backorderable inventory units to backorder. (At this point, an "insufficient stock" error is raised if there's leftover inventory that couldn't be allocated from any stock location.)
    4.
    Then, the list of on-hand and backorderable inventory units is converted into packages, one package per stock location.
    5.
    Finally, the list of packages is passed to the configured stock splitters, which may further split up the original packages.
At this point, we have our final list of packages. It's now time to convert them into real shipments and estimate their shipping rates.

Rate estimation

The rate estimation process follows a similar pattern:
    1.
    First, the coordinator converts the packages into shipments.
    2.
    Then, it calls the configured estimator to calculate the shipping rates for each package.
    3.
    Finally, it links the shipping rates for each package to the corresponding shipment.
Because the estimator is configurable, you can override the estimation logic however you want.
However, for the purpose of this guide, we'll assume you're using the default Spree::Stock::Estimator, and we'll explain its process too:
    1.
    First, the estimator retrieves the list of shipping methods available for the package being estimated. This determination takes into account the current store, the order's shipping address and the currency on the shipping method's calculator.
    2.
    Then, it calculates the rate for each available shipping method, by using the calculator configured on the shipping method.
    3.
    Then, it filters out any rates that belong to backend-only shipping methods, in case the calculation is being performed from the storefront.
    4.
    Then, it selects the default shipping rate by using the configured shipping rate selector.
    5.
    Finally, it sorts the shipping rates by using the configured shipping rate sorter.
The result of this process is a sorted list of shipping rates for the original package, with a default shipping rate already pre-selected for the user.

Customizing package creation

There are several pieces you can customize in the package creation process:
    the stock location filter, to customize which stock locations Solidus picks inventory from;
    the location sorter, to customize how Solidus prioritizes stock locations to pick inventory from;
    the allocator, to customize how Solidus prioritizes inventory units to allocate from the filtered and sorted stock locations;
    the splitters, to customize how Solidus splits the allocated inventory units in packages.
In the next paragraphs, we'll see a brief example for each of these customizations!

Stock location filter

The default stock location filter simply filters out the inactive stock locations.
Let's say you are a giant brand with warehouses all over the US, and you only ever want to ship from the stock locations in the customer's state.
You can do that by writing a custom stock location filter that looks like this:
app/models/awesome_store/stock/location_filter/order_state.rb
1
module AwesomeStore
2
module Stock
3
module LocationFilter
4
class OrderState < Spree::Stock::LocationFilter::Base
5
def filter
6
stock_locations.active.where(state: order.ship_address.state)
7
end
8
end
9
end
10
end
11
end
Copied!
As you can see, the logic is pretty simple: we take an initial list of stock locations (the default stock coordinator will simply pass all stock locations here) and then we only pick the ones that are active and where the state matches the state on the order's shipping address.
In order to start using our new stock location filter, you just need to configure it:
config/initializers/spree.rb
1
Spree.config do |config|
2
# ...
3
4
config.stock.location_filter_class = 'AwesomeStore::Stock::LocationFilter::OrderState'
5
end
Copied!

Stock location sorter

By default, stock locations are unsorted, but Solidus provides a built-in DefaultFirst sorter that will put the default stock location first.
Let's say that you ship from a mix of your own warehouses and third-party warehouses, and you want to ship from your own warehouses first in order to minimize fulfillment cost.
You could do this with a custom stock location sorter:
app/models/awesome_store/stock/location_sorter/self_owned_first.rb
1
module AwesomeStore
2
module Stock
3
module LocationSorter
4
class SelfOwnedFirst < Spree::Stock::LocationSorter::Base
5
def sort
6
# We're assuming the `self_owned` column is `true` when the warehouse
7
# is self-owned, and `false` when it's ownerd by a third-party.
8
stock_locations.order(self_owned: :desc)
9
end
10
end
11
end
12
end
13
end
Copied!
The implementation is pretty similar to that of the stock location filter: you take an initial list of sorted stock locations and you return a sorted list.
Now that you have implemented your sorter, you need to enable it:
config/initializers/spree.rb
1
Spree.config do |config|
2
# ...
3
4
config.stock.location_sorter_class = 'AwesomeStore::Stock::LocationSorter::SelfOwnedFirst'
5
end
Copied!

Stock allocator

The default stock allocator picks on hand inventory units before backordered inventory units.
Let's say you're a drop-shipping business, but you also hold a tiny amount of inventory on-hand for VIP customers or other special cases. In this case, you want to make sure you backorder all items and never touch your on-hand inventory unless absolutely needed (e.g.., if the customer ordered an item that's not being produced anymore and cannot be backordered).
You could accomplish this with a custom stock allocator such as the following:
app/models/awesome_store/stock/allocator/backordered_first.rb
1
module AwesomeStore
2
module Stock
3
module Allocator
4
class BackorderedFirst < Spree::Stock::Allocator::Base
5
def allocate_inventory(desired)
6
# Allocate backordered inventory first
7
backordered = allocate_backordered(desired)
8
desired -= backordered.values.sum if backordered.present?
9
10
# Allocate any non-backorderable inventory from on-hand inventory
11
on_hand = allocate_on_hand(desired)
12
desired -= on_hand.values.sum if on_hand.present?
13
14
# `desired` at this point should be empty if we managed to
15
# allocate all required inventory
16
[on_hand, backordered, desired]
17
end
18
19
protected
20
21
# In these two methods, `availability` is a `Spree::Stock::Availability`
22
# instance, which maps a list of variants to their availability in the
23
# filtered stock locations
24
25
def allocate_backordered(desired)
26
allocate(availability.backorderable_by_stock_location_id, desired)
27
end
28
29
def allocate_on_hand(desired)
30
allocate(availability.on_hand_by_stock_location_id, desired)
31
end
32
33
def allocate(availability_by_location, desired)
34
# `availability_by_location` is a `Spree::StockQuantities` instance
35
# that makes it easier to perform operations on inventory units
36
availability_by_location.transform_values do |available|
37
# Find the desired inventory which is available at this location
38
packaged = available & desired
39
# Remove found inventory from desired
40
desired -= packaged
41
packaged
42
end
43
end
44
end
45
end
46
end
47
end
Copied!
This allocator is extremely similar to Solidus' default stock allocator, but it works backwards: it allocates backordered inventory units before starting to pick on-hand inventory units.
Because operations on inventory units can be a bit complicated for a developer to perform manually, Solidus provides two helper classes, Spree::Stock::Availability and Spree::StockQuantities, which make it easier to reason about and perform algebraic operations on inventory units. Feel free to take a look at their source code to understand how they work in detail.

Stock splitters

The default splitter chain will split packages by shipping category and then by availability (i.e., by separating on hand and backordered items in different packages).
There's also a Weight splitter that is not enabled by default, which will split packages so that they are all below a certain weight threshold.
An important aspect to understand about stock splitters is that, unlike all the other components of the stock system, you can have multiple stock splitters configured at the same time to form a splitter chain.
When the packages are ready to be split, Solidus will pass the initial list of packages to the first splitter in the chain, and each splitter is responsible for running its logic and passing the result to the next splitter in the chain, until the end of the chain is reached.
As an example, let's say you ship some frozen products that are packaged in dry ice. You want to split frozen products and regular products in separate packages.
You could accomplish this with a custom stock splitter such as the following:
1
module AwesomeStore
2
module Stock
3
module Splitter
4
class FrozenItems < Spree::Stock::Splitter::Base
5
def split(packages)
6
split_packages = []
7
8
packages.each do |package|
9
# Split each package in frozen and non-frozen items
10
split_packages += split_package(package)
11
end
12
13
# `return_next` is a helper that will pass the split
14
# packages to the next splitter in the chain
15
return_next split_packages
16
end
17
18
private
19
20
def split_package(package)
21
frozen_items = []
22
non_frozen_items = []
23
24
package.contents.each do |item|
25
# We are assuming that `Spree::Variant` responds to `#frozen?`
26
if item.variant.frozen?
27
frozen_items << item
28
else
29
non_frozen_items << item
30
end
31
end
32
33
# The `build_package` method is a helper that takes a
34
# list of items and builds a package with them.
35
[
36
# Build the package for frozen items
37
build_package(frozen_items),
38
# Build the package for non-frozen items
39
build_package(non_frozen_items),
40
]
41
end
42
end
43
end
44
end
45
end
Copied!
The implementation here is slightly more complicated than usual, so let's walk through it:
    1.
    First, we loop through each package that is passed to the splitter.
    2.
    Then, for each package, we separate the frozen and the non-frozen items in two separate packages.
    3.
    Then, we pass the final list of split packages to the next stock location splitter.
As you may imagine, the order of stock splitters is important to determine the final result of the splitter chain. When you implement a custom stock splitter, make sure to add it in the right place! If you want full control over the splitter chain, you can override the stock_splitters array completely rather than appending to it.
Now that we have our new splitter, we need to add it to the splitter chain:
config/initializers/spree.rb
1
Spree.config do |config|
2
# ...
3
4
config.environment.stock_splitters << 'AwesomeStore::Stock::Splitter::FrozenItems'
5
end
Copied!

Customizing rate estimation

As far as the rate estimation is concerned, there are two pieces you can customize:
    the estimator, to customize how Solidus calculates shipping rates for a shipment;
    the shipping rate selector, to customize how Solidus selects a default shipping rate;
    the shipping rate sorter, to customize how Solidus sorts shipping rates.
In the next paragraphs, we'll see a brief example for each of these customizations!

Shipping rate estimator

The default shipping rate estimator simply uses the shipping methods you have configured on your store, along with the respective calculators, to calculate the right shipping rate for each shipment/shipping method combination.
For large/complex stores, using fixed shipping rates, or attempting to re-create the shipping rate calculation logic used by carriers, is simply not feasible. When that's the case, you can override Solidus' shipping rate estimator to bypass the configured shipping methods completely and use an external data source such as an API (e.g., EasyPost).
Let's see an example with EasyPost:
1
module AwesomeStore
2
module Stock
3
class EasypostEstimator
4
def shipping_rates(package, _frontend_only = true)
5
# Create a new shipment with the EasyPost API and
6
easypost_rates = get_rates_from_easypost(package)
7
8
# Retrieve the rates for the EasyPost shipment
9
shipping_rates = easypost_rates.map do |easypost_rate|
10
# Turn the EasyPost rate into a Solidus shipping rate
11
build_shipping_rate(easypost_rate)
12
end
13
14
# Choose the default shipping rate through the configured shipping rate selector
15
unless shipping_rates.empty?
16
default_shipping_rate = Spree::Config.shipping_rate_selector_class.new(shipping_rates).find_default
17
default_shipping_rate.selected = true
18
end
19
20
# Sort the shipping rates through the configured shipping rate sorter
21
Spree::Config.shipping_rate_sorter_class.new(shipping_rates).sort
22
end
23
24
private
25
26
def get_rates_from_easypost(package)
27
# API integration logic here...
28
end
29
30
def build_shipping_rate(easypost_rate)
31
# Find or create a new shipping method in Solidus
32
# for this EasyPost rate
33
shipping_method = Spree::ShippingMethod.find_or_create_by(
34
carrier: easypost_rate.carrier,
35
service_level: easypost_rate.service,
36
) do |shipping_method|
37
shipping_method.name = "#{easypost_rate.carrier} #{easypost_rate.service}"
38
shipping_method.calculator = Spree::Calculator::Shipping::FlatRate.create
39
shipping_method.shipping_categories = Spree::ShippingCategory.all
40
shipping_method.available_to_users = true
41
end
42
43
# Build a Solidus shipping rate for this EasyPost rate
44
Spree::ShippingRate.new(
45
shipping_method: shipping_method,
46
name: "#{easypost_rate.carrier} #{easypost_rate.service}",
47
cost: easypost_rate.rate,
48
)
49
end
50
end
51
end
52
end
Copied!
The API integration logic has been left out on purpose, so let's walk through the rest of the implementation:
    1.
    First, we call the EasyPost API to create a shipment and retrieve the proposed rates.
    2.
    Then, we transform the EasyPost rates into Spree::ShippingRate instances.
    3.
    Then, we choose the default rate through the configured shipping rate selector.
    4.
    Then, we sort the rates through the configured shipping rate sorter.
    5.
    Finally, we return the sorted rates.
Notice how, in the build_shipping_rate method, we are finding or creating the shipping method for each EasyPost rate, since we're not relying on the shipping methods stored in the DB but reading them directly from EasyPost. An alternative would be to only generate EasyPost rates for shipping methods that already exist in the Solidus DB (e.g., to give admins more granular control over which shipping methods to enable).
Now that our estimator is ready, we just need to configure it:
config/initializers/spree.rb
1
Spree.config do |config|
2
# ...
3
4
config.stock.estimator_class = 'AwesomeStore::Stock::EasypostEstimator'
5
end
Copied!

Shipping rate selector

The default shipping rate selector pre-selects the lowest-priced shipping rate for the user.
What if we wanted to pre-select the shipping rate with the quickest delivery time? We could easily accomplish this through a custom shipping rate selector:
1
module AwesomeStore
2
module Stock
3
class ShippingRateSelector
4
attr_reader :shipping_rates
5
6
def initialize(shipping_rates)
7
@shipping_rates = shipping_rates
8
end
9
10
def find_default
11
# This assumes `Spree::ShippingRate` responds to `#delivery_time`
12
# with the number of days it will take to deliver the package
13
shipping_rates.min_by(&:delivery_time)
14
end
15
end
16
end
17
end
Copied!
The logic here is extremely simple: we accept a list of shipping rates as input, and we simply return the shipping rate that needs to be selected as the default, by looking for the shipping rate with the lowest delivery time.
As usual, we now need to tell Solidus to use our shipping rate selector:
config/initializers/spree.rb
1
Spree.config do |config|
2
# ...
3
4
config.stock.shipping_rate_selector_class = 'AwesomeStore::Stock::ShippingRateSelector'
5
end
Copied!

Shipping rate sorter

The default shipping rate sorter sorts the shipping rates by price, from lowest to highest.
Let's say we also want to sort the shipping rates by delivery time, so that the customer can more easily make an informed decision.
All we need to do is create a custom shipping rate sorter:
1
module AwesomeStore
2
module Stock
3
class ShippingRateSorter
4
attr_reader :shipping_rates
5
6
def initialize(shipping_rates)
7
@shipping_rates = shipping_rates
8
end
9
10
def sort
11
# This assumes `Spree::ShippingRate` responds to `#delivery_time`
12
# with the number of days it will take to deliver the package
13
shipping_rates.sort_by(&:delivery_time)
14
end
15
end
16
end
17
end
Copied!
As you can see, shipping rate sorters are very simple: they accept a list of shipping rates and return it sorted. In our case, we are sorting the shipping rates by a custom delivery_time attribute.
Then, we just tell Solidus to use our custom shipping rate sorter:
config/initializers/spree.rb
1
Spree.config do |config|
2
# ...
3
4
config.stock.shipping_rate_sorter_class = 'AwesomeStore::Stock::ShippingRateSorter'
5
end
Copied!
Last modified 3mo ago