Skip to main content

Overview

Spree’s pricing system supports both simple single-currency pricing and advanced multi-currency, rule-based pricing through Price Lists. Every Variant can have multiple prices — a base price per currency, plus additional prices from Price Lists that apply conditionally based on rules like geography, customer segment, or quantity.

Prices

Price objects track a price for a particular currency and variant combination. For instance, a Variant may be available for $15 (15 USD) and €7 (7 Euro). Price object contains 3 important attributes:
AttributeDescriptionExample Value
amountThe current selling price of the variant in the specified currency.99.90
compare_at_amountThe recommended retail price of the variant in the specified currency. This can be used to display crossed out prices in the storefront.129.90
currencyThe ISO code for the currency in which the amount and compare_at_amount are denominated.USD
If a product doesn’t have a price in the selected currency it won’t show up in the Storefront and Storefront API by default.
To fetch a list of currencies that given product is available in, call prices to get a list of related Price objects:
product.prices
=> [#<Spree::Price id: 2 ...]
To find a list of currencies that Variant is available in, call prices to get a list of related Price objects:
product.default_variant.prices
=> [#<Spree::Price id: 2 ...]
To find Product price in a selected currency via ISO symbol:
product.price_in('EUR')
=> #<Spree::Price id: 232, variant_id: 232, amount: 0.8499e2, currency: "EUR", deleted_at: nil, created_at: "2021-08-16 19:41:55.888522000 +0000", updated_at: "2021-08-16 19:41:55.888522000 +0000", compare_at_amount: nil, preferences: nil>
If there’s no price set for requested currency this will return a new Price object with the currency set to the requested currency. It will not be a persisted object.
To find Variant’s price in a selected currency:
product.default_variant.price_in('EUR')
=> #<Spree::Price id: 232, variant_id: 232, amount: 0.8499e2, currency: "EUR", deleted_at: nil, created_at: "2021-08-16 19:41:55.888522000 +0000", updated_at: "2021-08-16 19:41:55.888522000 +0000", compare_at_amount: nil, preferences: nil>
There are also other helpful methods available such as:
product.default_variant.amount_in('EUR')
 => 0.8499e2
product.default_variant.amount_in('EUR').to_s
 => "84.99"
product.price_in('EUR').display_amount.to_s
 => "€84.99"
Spree behind the scenes uses Ruby Money gem with some additional tweaks.

Price Lists

Price Lists are available as of Spree 5.2.
Price Lists allow you to create different pricing strategies for your products based on various conditions such as markets, customer zones, specific users, or order quantities. This enables advanced pricing scenarios like:
  • Market-based pricing - Different prices for different Markets (e.g., North America vs Europe)
  • Regional pricing - Different prices for different geographic zones
  • Wholesale/B2B pricing - Special prices for business customers
  • Volume discounts - Tiered pricing based on quantity purchased
  • Promotional pricing - Time-limited special offers
  • VIP customer pricing - Exclusive prices for specific users

How Price Lists Work

Each Price List contains a collection of prices for specific variants and currencies. When a customer views a product or adds it to their cart, Spree’s pricing resolver determines which price to use based on:
  1. Price List priority - Price Lists are ordered by position; higher priority lists are checked first
  2. Status - Only active or scheduled Price Lists are considered
  3. Date range - The current time must fall within starts_at and ends_at (if set)
  4. Price Rules - All configured rules must match (or any, depending on match_policy)
If no applicable Price List is found, the base price (price without a Price List) is used.

Price List Attributes

AttributeDescription
nameHuman-readable name for the Price List
statusCurrent status: draft, active, scheduled, or inactive
starts_atOptional start date/time when the Price List becomes applicable
ends_atOptional end date/time when the Price List stops being applicable
match_policyHow rules are evaluated: all (every rule must match) or any (at least one rule must match)
positionPriority order for evaluation (lower numbers = higher priority)

Price Rules

Price Rules define conditions that must be met for a Price List to apply. Spree includes five built-in rule types:
Applies the Price List based on the current Market. This is the recommended approach for regional pricing when using Markets.
# Example: Create a Price List for the European market
price_list = Spree::PriceList.create!(
  name: 'EU Market Pricing',
  store: current_store,
  status: 'active',
  match_policy: 'all'
)

eu_market = Spree::Market.find_by!(name: 'Europe')
price_list.price_rules.create!(
  type: 'Spree::PriceRules::MarketRule',
  preferences: { market_ids: [eu_market.id] }
)
If market_ids is empty, the rule matches any market. If the pricing context has no market, the rule does not match.
Applies the Price List based on the customer’s tax/shipping zone. Useful for regional or country-specific pricing when not using Markets.
# Example: Create a Price List for EU customers
price_list = Spree::PriceList.create!(
  name: 'EU Pricing',
  store: current_store,
  status: 'active',
  match_policy: 'all'
)

eu_zone = Spree::Zone.find_by!(name: 'EU')
price_list.price_rules.create!(
  type: 'Spree::PriceRules::ZoneRule',
  preferences: { zone_ids: [eu_zone.id] }
)
Applies the Price List to specific users. Useful for VIP customers, wholesale accounts, or B2B pricing.
# Example: Create a VIP Price List
price_list = Spree::PriceList.create!(
  name: 'VIP Customers',
  store: current_store,
  status: 'active',
  match_policy: 'all'
)

# Find users tagged with 'VIP'
vip_user_ids = Spree.user_class.tagged_with('VIP').pluck(:id)

price_list.price_rules.create!(
  type: 'Spree::PriceRules::UserRule',
  preferences: { user_ids: vip_user_ids }
)
Applies the Price List to members of specific customer groups. Useful for wholesale tiers, loyalty programs, or membership-based pricing.
# Example: Create a wholesale Price List
price_list = Spree::PriceList.create!(
  name: 'Wholesale Pricing',
  store: current_store,
  status: 'active',
  match_policy: 'all'
)

wholesale_group = Spree::CustomerGroup.find_by!(name: 'Wholesale')
price_list.price_rules.create!(
  type: 'Spree::PriceRules::CustomerGroupRule',
  preferences: { customer_group_ids: [wholesale_group.id] }
)
Applies the Price List based on quantity purchased. Useful for bulk discounts or tiered pricing.
# Example: Create volume-based pricing tiers
# Tier 1: 10-49 units
tier1 = Spree::PriceList.create!(
  name: 'Bulk Tier 1 (10-49)',
  store: current_store,
  status: 'active',
  position: 2
)
tier1.price_rules.create!(
  type: 'Spree::PriceRules::VolumeRule',
  preferences: { min_quantity: 10, max_quantity: 49 }
)

# Tier 2: 50+ units (higher priority)
tier2 = Spree::PriceList.create!(
  name: 'Bulk Tier 2 (50+)',
  store: current_store,
  status: 'active',
  position: 1
)
tier2.price_rules.create!(
  type: 'Spree::PriceRules::VolumeRule',
  preferences: { min_quantity: 50, max_quantity: nil }
)

Creating Custom Price Rules

You can create custom Price Rules by subclassing Spree::PriceRule:
# app/models/spree/price_rules/membership_rule.rb
module Spree
  module PriceRules
    class MembershipRule < Spree::PriceRule
      preference :membership_levels, :array, default: []

      def applicable?(context)
        return false unless context.user
        return true if preferred_membership_levels.empty?

        preferred_membership_levels.include?(context.user.membership_level)
      end

      def self.description
        'Apply pricing based on membership level'
      end
    end
  end
end
Then register it in your initializer:
# config/initializers/spree.rb
Rails.application.config.after_initialize do
  Rails.application.config.spree.pricing.rules << Spree::PriceRules::MembershipRule
end

Pricing Context

The Spree::Pricing::Context object carries all the information needed to resolve the correct price. It encapsulates the current pricing scenario and is used by the Spree::Pricing::Resolver to find the best applicable price.

Context Attributes

AttributeTypeRequiredDefaultDescription
variantSpree::VariantYes-The variant for which to resolve the price
currencyStringYes-ISO 4217 currency code (e.g., 'USD', 'EUR')
storeSpree::StoreNoSpree::Current.storeThe store context; Price Lists are scoped to stores
zoneSpree::ZoneNoSpree::Current.zoneThe tax/shipping zone for zone-based pricing rules. Falls back to Spree::Zone.default_tax if not set.
marketSpree::MarketNoSpree::Current.marketThe current Market for market-based pricing rules. Falls back to the store’s default market.
userUser classNonilThe current user; used by User Rules for customer-specific pricing
quantityIntegerNonilThe quantity being purchased; used by Volume Rules for tiered pricing
dateTimeNoTime.currentThe date/time for price resolution; used to check Price List date ranges
orderSpree::OrderNonilThe current order; provides additional context for price resolution

Understanding the Market and Zone Attributes

The market attribute is the primary way to scope pricing geographically. Markets bundle a set of countries with a currency and locale, making them the natural unit for regional pricing. How market is resolved:
  • Spree::Current.market is set per-request, typically based on the customer’s country
  • Falls back to the store’s default market if not explicitly set
  • The Market Rule on Price Lists uses this attribute to match market-specific prices
# Market is automatically available in the pricing context
Spree::Current.market  # => #<Spree::Market name: "Europe", currency: "EUR">

context = Spree::Pricing::Context.new(variant: variant, currency: 'EUR')
context.market  # => Spree::Current.market
The zone attribute is still available for zone-based pricing via the Zone Rule. Zones are primarily used for tax and shipping calculations, while Markets are the recommended approach for regional pricing. Both can be used together — for example, a Market Rule for currency-based pricing and a Zone Rule for state-level tax-inclusive pricing.

Creating a Context

There are several ways to create a pricing context:
For basic price lookups when you only need currency-based pricing:
context = Spree::Pricing::Context.from_currency(variant, 'USD')

# This is equivalent to:
context = Spree::Pricing::Context.new(
  variant: variant,
  currency: 'USD'
)
For full control over all pricing parameters:
context = Spree::Pricing::Context.new(
  variant: variant,
  currency: 'EUR',
  store: Spree::Store.find_by!(code: 'eu-store'),
  zone: Spree::Zone.find_by!(name: 'EU'),
  market: Spree::Market.find_by!(name: 'Europe'),
  user: current_user,
  quantity: 10,
  date: 1.week.from_now  # Preview future pricing
)

Resolving the Price

Once you have a context, use the Spree::Pricing::Resolver to find the best price:
resolver = Spree::Pricing::Resolver.new(context)
price = resolver.resolve  # Returns a Spree::Price object

# The resolver:
# 1. Finds all applicable Price Lists (active/scheduled, within date range)
# 2. Filters by Price Rules (market, zone, user, quantity, etc.)
# 3. Returns the first matching price (by Price List position)
# 4. Falls back to the base price if no Price List applies
Price resolution results are cached for 15 minutes using the context’s cache key. The cache key includes all context attributes (including market), so different scenarios are cached separately.

Getting Prices: price_for vs price_in

Spree provides two methods for fetching variant prices. Understanding when to use each is important for correct pricing behavior. The price_for method resolves prices using the full Price List system. It considers all applicable Price Lists, rules, and returns the best matching price. This is the recommended method for most use cases.
# Using a Pricing Context object
context = Spree::Pricing::Context.from_order(variant, order)
price = variant.price_for(context)

# Using an options hash (context is built automatically)
price = variant.price_for(
  currency: 'USD',
  store: Spree::Current.store,
  zone: order.tax_zone,
  user: current_user,
  quantity: 5
)

# Display the resolved price
price.display_amount.to_s  # => "$89.99"
Use price_for when:
  • Displaying prices to customers in the storefront
  • Calculating line item prices during checkout
  • You need market-based, zone-based, user-based, or volume-based pricing
  • You want prices from active Price Lists to apply

price_in (Base Prices Only)

The price_in method returns only the base price (prices without a Price List) for a given currency. It bypasses the Price List system entirely.
# Get the base price in USD
base_price = variant.price_in('USD')
base_price.amount  # => 99.99
Use price_in when:
  • You specifically need the base price, ignoring all Price Lists
  • Managing prices in the admin interface
  • Importing/exporting base pricing data
  • Backward compatibility with pre-5.2 code
price_in is maintained for backward compatibility. For customer-facing pricing, always use price_for to ensure Price Lists are applied correctly.

Comparison

MethodPrice ListsRules AppliedUse Case
price_forYesYesStorefront, checkout, customer-facing
price_inNoNoAdmin, base price management
# Example: Same variant, different results
variant = Spree::Variant.find(1)

# Base price (no Price Lists)
variant.price_in('USD').amount  # => 100.00

# Resolved price (with VIP Price List applied)
context = Spree::Pricing::Context.new(
  variant: variant,
  currency: 'USD',
  user: vip_customer
)
variant.price_for(context).amount  # => 80.00 (20% VIP discount)

Managing Price List Products

Products can be added to a Price List, which creates placeholder prices for all variants:
price_list = Spree::PriceList.find(1)

# Add products to the Price List
price_list.add_products([product1.id, product2.id])

# Update prices for the Price List
price_list.prices.each do |price|
  price.update(amount: calculate_special_price(price.variant))
end

# Remove products from the Price List
price_list.remove_products([product1.id])

Time-Based Pricing

Price Lists support scheduling through starts_at and ends_at attributes:
# Create a Black Friday sale
black_friday = Spree::PriceList.create!(
  name: 'Black Friday 2025',
  store: current_store,
  status: 'scheduled',
  starts_at: Time.zone.parse('2025-11-28 00:00'),
  ends_at: Time.zone.parse('2025-11-28 23:59'),
  position: 1  # Highest priority
)
Scheduled Price Lists automatically become applicable when the current time falls within their date range. They don’t need to be manually activated.
  • Products — Products, Variants, and base prices
  • Markets — Geographic regions with currency and locale
  • Taxes — Tax categories, tax rates, and zones
  • Promotions — Discount-based pricing via promotion rules