Agent Skill · Spree Commerce

spree-pricing

Use when the user is working with Spree pricing — variant prices, currency-specific pricing, sale/compare-at prices, price lists (5.3), price rules, EU Omnibus compliance (PriceHistory + prior_price), tax-inclusive pricing. Common phrasings include "set price", "compare at price", "sale price", "price list", "multi-currency pricing", "prior price", "Omnibus", "EU pricing law", "lowest price in 30 days", "tax inclusive", "VAT". Provides the price graph and the regulatory bits.

Provider: Spree Commerce Path in repo: skills/spree-pricing/SKILL.md

Skill body

Spree Pricing

Commands below use the Spree CLI form (spree …, Docker). On a classic Rails app without the CLI (typical pre-5.4), use the native mapping in the spree-project skill — bin/rails / bundle exec rake from the app root, paths without the backend/ prefix.

Pricing in Spree is per-variant, per-currency. Optional layers add price lists (per-customer or per-market), historical tracking (for EU Omnibus compliance), and rule-based variations.

The price graph

Variant
  ├── Price (one per currency)
  │     ├── amount             — what the customer pays (gross or net depending on tax config)
  │     ├── compare_at_amount  — was-price / strikethrough price (sale messaging)
  │     ├── currency
  │     └── price_list (5.3)   — nil for the default storefront price
  ├── PriceHistory × n         — historical amount changes (EU Omnibus)
  └── PriceRule × n            — conditional price overrides (5.3)

Each Variant has at least one Price per currency the store sells in. The cart pipeline picks the right Price based on Spree::Current.currency and the customer’s PriceList (if any).

Setting a price

variant.prices.create!(
  currency: 'USD',
  amount: 39.99,
  compare_at_amount: 49.99   # strikethrough — shows as "was $49.99 now $39.99"
)

The canonical, idempotent setter is variant.set_price('USD', 39.99, 49.99) — it find-or-initializes the base Price for the currency (unlike prices.create!, which raises if a USD base price already exists, due to a unique DB index on variant + currency). Note the third argument is compare_at_amount and is assigned unconditionally — calling set_price('USD', 39.99) clears any existing compare-at price. Read it back with variant.price_in('USD'), which returns the base Spree::Price for that currency.

For most stores, prices are created via the admin UI (Products → variant edit) or bulk-imported via CSV. Spree::Variant#price is deprecated (removed in 6.0) — it returns the base-price amount in the default store’s default currency, not a Spree::Price in the current currency. Use variant.price_in('EUR') (returns the base Spree::Price for a currency), variant.amount_in('EUR') (BigDecimal amount), or variant.price_for(currency: 'EUR', user: user) to get the price resolved through price lists (Spree::Pricing::Resolver).

variant.price_in(Spree::Current.currency)   # => Spree::Price
variant.cost_price                          # => internal cost (not customer-facing)

Multi-currency pricing

Each Variant needs a Price per currency the store sells in. There’s no automatic conversion — you set EUR, GBP, USD explicitly. (The store’s default_currency determines what the admin defaults to.)

variant.prices.create!(currency: 'USD', amount: 39.99)
variant.prices.create!(currency: 'EUR', amount: 35.00)
variant.prices.create!(currency: 'GBP', amount: 31.00)

For bulk currency updates (e.g. “raise all USD prices 10%”):

Spree::Price.where(currency: 'USD', price_list_id: nil).update_all('amount = amount * 1.1')

Note update_all bypasses callbacks, so PriceHistory is NOT recorded (and the seed task won’t backfill changes — it skips prices that already have history). If you have PriceHistory enabled, use the batched update! pattern shown under “Bulk price update” instead, which fires the history callback per record.

PriceList (5.3)

A PriceList is a named pricing context — “Wholesale”, “VIP”, “B2B Tier 1”. Each PriceList has its own Prices, separate from the default storefront prices.

wholesale = Spree::PriceList.create!(
  store: current_store,
  name: 'Wholesale',
  match_policy: 'all',     # 'all' (all rules must match) or 'any'
  starts_at: nil,          # optional time window
  ends_at: nil,
  status: 'active'
)

variant.prices.create!(
  price_list: wholesale,
  currency: 'USD',
  amount: 25.00
)

A PriceList is gated by PriceRule records attached to it. A customer “qualifies” for a PriceList only when its rules match the pricing context — zone, market, customer group, user, or volume. The cart pipeline picks the highest-priority matching PriceList’s price; falls back to the default (price_list: nil) otherwise.

This is the foundation for B2B pricing tiers, member discounts, and per-market pricing. Pre-5.3 stores used promotions for this — PriceList is cleaner because the price is the displayed price (no “20% off at checkout” surprise).

PriceRule (5.3)

A PriceRule is a condition gating a PriceList. PriceRule is STI; subclasses live in Spree::PriceRules::*:

Subclass Matches when…
Spree::PriceRules::CustomerGroupRule The customer is in a specified CustomerGroup
Spree::PriceRules::MarketRule The order’s market is in a specified set of markets (5.4)
Spree::PriceRules::UserRule A specific User is logged in
Spree::PriceRules::VolumeRule The line item quantity hits a threshold
Spree::PriceRules::ZoneRule The shipping zone matches

Each subclass implements applicable?(context) where context is a Spree::Pricing::Context (store, currency, zone, market, user, quantity, date, etc.).

wholesale = Spree::PriceList.find_by(name: 'Wholesale')

wholesale.price_rules << Spree::PriceRules::CustomerGroupRule.new(
  preferred_customer_group_ids: [b2b_group.id]
)

Rules combine via the parent PriceList’s match_policy (all = AND, any = OR).

EU Omnibus compliance (PriceHistory + prior_price)

The EU Omnibus Directive (in force since 2022) requires retailers to display the lowest price in the last 30 days alongside any “was-price” / sale messaging. Spree 5.4 added PriceHistory and the prior_price serializer field to support this.

How it works

Spree::Price.update(amount: 30.00)
  ↓ (model callback)
Spree::PriceHistory.create(
  price: self_price,
  amount: 30.00,
  recorded_at: Time.current
)

Every base-price amount change creates a PriceHistory entry (price-list prices and compare_at-only changes are not tracked; recording requires Spree::Config[:track_price_history]). The Spree::Price#prior_price method returns the Spree::PriceHistory record with the lowest amount recorded in the last 30 days (or nil if there’s no history in that window):

price.amount                # => 25.00 (current sale price)
price.prior_price           # => #<Spree::PriceHistory amount: 25.00, ...> or nil
price.prior_price&.amount   # => lowest amount recorded in the window

Note: the price-change callback records the new amount itself, so the just-set sale price is included in the 30-day window and can be the “lowest” returned.

The Store API exposes prior_price as an expandable field on product and variant endpoints (?expand=prior_price), serialized as a PriceHistory object (amount, amount_in_cents, currency, display_amount, recorded_at). EU storefronts display it alongside the current sale price.

Configuration

# config/initializers/spree.rb
Spree::Config[:track_price_history] = true        # default true; disable for non-EU stores
Spree::Config[:price_history_retention_days] = 60 # default 30; controls the prune task

Seeding history for existing stores

If you turned on PriceHistory on an existing store, run the seed task to backfill an initial PriceHistory entry per Price:

spree rake spree:price_history:seed

The task is idempotent — it skips Prices that already have a PriceHistory entry.

Pruning old entries

PriceHistory grows unboundedly. The prune task drops entries older than the configured retention:

spree rake spree:price_history:prune

Schedule this in your background job runner (Sidekiq cron, Heroku scheduler, etc.) to run nightly.

Tax-inclusive vs tax-exclusive pricing

Spree supports both modes per Zone / Country:

Mode What’s stored in Price.amount Customer sees
Tax-exclusive (US default) Net (pre-tax) Net + “Tax at checkout”
Tax-inclusive (EU default) Gross (post-tax) Gross with “incl. VAT”

Configured via Spree::TaxRate.included_in_price per rate. The Markets system (5.4+) sets a default per market.

The cart pipeline displays prices according to Spree::Current.market’s setting. Prices are entered as-is in the admin — no price form asks gross vs net. The interpretation comes from the tax configuration: TaxRate.included_in_price drives the tax math, and the per-market tax_inclusive flag (Settings → Markets) records the market-level default.

EU stores: always gross-stored. This is what Omnibus requires for transparency. US stores: always net-stored. Sales tax is added at the cart level.

Mixing the two (US store selling to EU customers) requires careful Market configuration.

Common pricing problems

“Customer sees the wrong price”

Walk this list:

  1. Currency mismatch? Spree::Current.currency should be the customer’s. Check the Channel / Market config.
  2. PriceList? If a PriceList’s rules match the pricing context, that PriceList’s price wins. Walk the candidate lists for the request context: Spree::Current.price_lists returns the memoized Spree::PriceList.for_context(Spree::Current.global_pricing_context) (status active/scheduled, within date range, by position). To check rule matching for a specific customer, build a context and test each list: ctx = Spree::Pricing::Context.new(currency: 'USD', store: current_store, user: user); Spree::PriceList.for_context(ctx).select { |pl| pl.applicable?(ctx) }. Or just inspect the resolved price directly: variant.price_for(currency: 'USD', user: user).
  3. Tax mode? The amount stored is gross or net depending on the Market — make sure the display logic matches the store setting.
  4. Cache stale? Catalog endpoints heavily cache. After price changes, touch the product (product.touch) to invalidate.

“PriceHistory not populating”

Confirm Spree::Config[:track_price_history] is true (default). Verify entries via Spree::Price.first.price_histories.count.

“Bulk price update — what’s the right pattern?”

For broad updates:

Spree::Price.where(currency: 'USD', price_list_id: nil).find_in_batches(batch_size: 500) do |batch|
  Spree::Price.transaction do
    batch.each { |p| p.update!(amount: p.amount * 1.1) }
  end
end

find_in_batches keeps memory bounded; the transaction ensures atomicity per batch. After: no PriceHistory action needed — each update! fires the price-history callback automatically (when Spree::Config[:track_price_history] is enabled, the default). Reindex search if prices affect ranking.

“Display price doesn’t match cart total”

The display price uses the variant’s Price for Spree::Current.currency. The cart total includes adjustments (promotions, taxes, shipping). They should agree on item subtotal unless a Promotion or PriceRule is changing the line-item amount.

order.line_items.first.price is the frozen price at the time the item was added. If the storefront PDP shows a different (newer) price, it’s because the Variant’s Price changed after the customer added to cart. This is intentional — cart contents don’t auto-update.

Where to read further