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.

spree/agent-skills81 installsMITSynced Aug 26

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: spree-pricing
description: 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.
license: MIT
---

# 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

```ruby
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`).

```ruby
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.)

```ruby
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%"):

```ruby
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.

```ruby
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
)
wholesale.activate          # status is a state machine (draft → active); activate/deactivate/schedule

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::ChannelRule` | The order's channel is in a specified set of channels |
| `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` still exists so legacy rows load, but it's deliberately excluded from the admin "Add rule" picker — zones are slated for removal in 6.0. Don't use it in new code.)

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

```ruby
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):

```ruby
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

```ruby
# 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:

```bash
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:

```bash
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:
```ruby
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

- **Core concepts:** `node_modules/@spree/docs/dist/developer/core-concepts/pricing.md`
- **Taxes:** `node_modules/@spree/docs/dist/developer/core-concepts/taxes.md`
- **Source:** `Spree::Price`, `Spree::PriceHistory`, `Spree::PriceList`, `Spree::PriceRule` in the installed `spree_core` gem.

More Accessibility skills

← All Accessibility skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY