How to use
- Copy the installation command and review the source package before installing it in your own agent environment.
- Check the source document for required tools, credentials and supported hosts.
- Describe your input and desired deliverable; review the output against the source material.
npx skills add https://github.com/spree/agent-skills --skill spree-pricingBefore you start
- Installation does not include a model subscription, API allowance or permission to access your files.
- Repository stars and installation counts describe activity, not an independent quality or security test.
Rendered from the source repository. Headings, examples, code, tables, links, and referenced images are preserved.
Spree Pricing
A price in Spree 6 is always an amount plus a currency on a variant — there is no implicit "the" price. On top of the base prices sit price lists (conditional overrides), resolved per request from a pricing context.
Variant ── Price (price_) × n
├── base prices: price_list_id = nil, one per currency (what everyone pays by default)
└── list prices: price_list_id = pl_…, per currency, optional min_quantity (quantity breaks)
PriceList (pl_) ── PriceRule × n (STI: MarketRule, ChannelRule, VolumeRule, …)
└── optionally owned by one Catalog (cat_) → audience = the catalog's assignments
Price ── PriceHistory × n (base-price changes, for EU Omnibus)Tax-inclusive vs exclusive display and VAT restatement live in spree-taxes.
Base prices: price_in / amount_in / set_price
variant.set_price('USD', 39.99) # upsert the USD base price (saves immediately if the variant is persisted)
variant.set_price('USD', 39.99, 49.99) # 3rd arg = compare_at_amount; omitting it CLEARS compare-at
variant.set_price('EUR', nil) # blank amount deletes that currency's base price
variant.price_in('USD') # => Spree::Price (unsaved blank Price if none — check .persisted? / .amount)
variant.amount_in('USD') # => BigDecimal or nil
variant.compare_at_amount_in('USD')
variant.price_in('USD').display_amount # "$39.99"
product.price_in('USD') # delegates to product.default_variant
variant.prices.base_prices.exists?(currency: 'EUR')variant.price,price=,default_price,display_price,currencydon't exist — pass the currency you mean.price_inis the base price only. For what a shopper actually pays (price lists applied), usevariant.price_for(...)(below).- There is no FX conversion: each currency the store sells in needs its own price. A product without a base price in the request currency is hidden from Store API listings.
Spree::Price#amount=parses localized numbers ('1,599.99'works).- Nested writes:
variant.prices = [{ currency:, amount:, compare_at_amount: }]upserts listed currencies and removes base prices for currencies not listed ([]clears all).product.prices = [...]forwards to the default variant.
Via the API
await admin.prices.create({ variant_id: 'variant_xxx', currency: 'USD', amount: '15.99' })
// Upsert many (matches on variant + currency + price_list_id + min_quantity)
await admin.prices.bulkUpsert({
prices: [
{ variant_id: 'variant_xxx', currency: 'USD', amount: '15.99', compare_at_amount: '19.99' },
{ variant_id: 'variant_xxx', currency: 'EUR', amount: '14.99' },
],
})
await admin.prices.bulkDestroy({ ids: ['price_xxx'] })spree api post /prices/bulk_upsert -d '{"prices":[{"variant_id":"variant_xxx","currency":"USD","amount":"15.99"}]}'/api/v3/admin/prices covers base prices and list overrides (price_list_id: 'pl_…'). Store API responses carry the already-resolved price / original_price for the request's currency, market and customer.
Price lists
| Attribute | Notes |
|---|---|
status | draft (default), active, scheduled, inactive — transitions via workflows (Spree.price_list_activate_workflow / price_list_deactivate_workflow; API PATCH /price_lists/:id/activate). Activating a list with a future starts_at marks it scheduled. |
starts_at / ends_at | Time window (store time zone). Scheduled lists apply automatically inside it — this is how you run a Black Friday sale. |
match_policy | all / any over its rules. |
position | Priority among standalone lists — lower first; first list that prices the variant wins. |
price_adjustment_percentage + price_adjustment_tiers | Catalog-owned lists only: derive prices as base × (1 + pct/100), optionally banded by quantity. Explicit price rows on the list still win. |
const pl = await admin.priceLists.create({
name: 'EU Black Friday',
starts_at: '2026-11-27T00:00:00Z',
ends_at: '2026-11-30T23:59:00Z',
match_policy: 'all',
rules: [{ type: 'market_rule', preferences: { market_ids: ['mkt_eu'] } }],
prices: [{ variant_id: 'variant_xxx', currency: 'EUR', amount: '19.99' }],
})
await admin.priceLists.activate(pl.id) // lists are born draftGET /api/v3/admin/price_lists/price_rule_types lists registered rule types and their preference schemas. Product membership (placeholder rows per variant × currency) goes through admin.priceLists.products.
Price rules — where / through what / how much
Type (api_type) | Class | Matches |
|---|---|---|
market_rule | Spree::PriceRules::MarketRule | Context market in market_ids (the geography rule — no zone rule) |
channel_rule | Spree::PriceRules::ChannelRule | Context channel in channel_ids (empty = any) |
volume_rule | Spree::PriceRules::VolumeRule | min_quantity ≤ line quantity ≤ max_quantity (nullable) — contextual |
user_rule, customer_group_rule | UserRule, CustomerGroupRule | Grandfathered (superseded? = true): existing rules keep matching, but pickers no longer offer them |
Who gets a price is not a rule — it's a catalog: attach the price list to a Spree::Catalog assigned to customer groups or companies (a channel names its default catalog via default_catalog_id). See spree-b2b.
Custom rule: subclass Spree::PriceRule, add preferences, implement applicable?(context), register in config/initializers/spree.rb with Rails.application.config.after_initialize { Spree.pricing.rules << MyApp::PriceRules::LoyaltyTierRule }. Override self.contextual? → true if it asks about the purchase (quantity-like) rather than the buyer, so it still applies on catalog-owned lists.
How a price resolves
variant.price_for(context_or_hash) → Spree::Pricing::PriceResolution → the store's pricing provider (default Spree::PricingProvider::Internal):
- Catalog-owned lists, nearest node first — the buyer's company node, then its ancestors; else their customer groups' catalogs; else the channel's default catalog. Within one node, the cheapest price wins (for the quantity being bought). Owned lists skip their audience rules but still check contextual rules (e.g.
VolumeRule), status and dates. - Standalone lists whose rules match, in
positionorder — first that prices the variant wins. - Base price (
price_list_id: nil).
ctx = Spree::Pricing::Context.new(
variant: variant, currency: 'EUR',
market: market, channel: channel, user: customer, company: company, quantity: 24
) # store/market/channel/country default from Spree::Current; date defaults to now
variant.price_for(ctx) # => Spree::Price or nil
variant.price_for(currency: 'EUR', user: customer, quantity: 24) # hash form
Spree::Pricing::Context.from_order(variant, cart) # uses the cart's own market/channel/customerThe Store API builds this context from X-Spree-Currency / X-Spree-Country, the channel of the API key, and the authenticated customer — no manual work. There's no in-process price cache. Only guest responses are publicly cacheable (Vary: Accept, x-spree-currency, x-spree-locale, x-spree-channel); authenticated ones are private, no-store. Country isn't in Vary but picks the market — add it to your CDN/app cache key (see spree-i18n).
External pricing (ERP, contract pricing): configure a pricing provider (Spree::PricingProvider::Base#price_for(context), handles?, cache_ttl) — it runs on the catalog read path, so declare a cache_ttl and decline anonymous contexts in handles?. See spree-providers.
Volume pricing and quantity breaks
Two ways, pick by shape:
- Quantity breaks on one list — price rows with
min_quantity > 1(requires aprice_list_id). The row with the highestmin_quantity≤ line quantity is charged. A variant with a ladder on a list is priced by the ladder alone.
await admin.prices.bulkUpsert({ prices: [
{ variant_id: 'variant_xxx', currency: 'USD', price_list_id: 'pl_trade', amount: '10.00' },
{ variant_id: 'variant_xxx', currency: 'USD', price_list_id: 'pl_trade', min_quantity: 24, amount: '8.50' },
]})- One list per tier with a
volume_rule(min_quantity/max_quantity) — when whole assortments or other rules differ per tier. Don't let tiers overlap.
A context without quantity (listings, exports) is priced at the bottom rung.
EU Omnibus: price history and prior_price
Every change to a base price amount writes a Spree::PriceHistory row (price-list prices aren't tracked). price.prior_price returns the lowest amount recorded in the last 30 days (it includes the just-set amount).
- Per-store preferences:
preferred_track_price_history(defaulttrue),preferred_price_history_retention_days(default 30). The globalSpree::Config[:track_price_history]is deprecated — set it on the store:
await admin.store.update({ preferred_track_price_history: false }) // non-EU store- Store API:
?expand=prior_priceon products/variants →{ amount, currency, display_amount, recorded_at }. - Rake:
spree rake spree:price_history:seed(one-time baseline, skips prices that already have history),spree rake spree:price_history:prune(schedule it — e.g. a Solid Queue recurring task).
Bulk updates
update_all skips callbacks → no price history, no collection re-matching. Prefer the Admin API bulk_upsert, or iterate:
Spree::Price.base_prices.where(currency: 'USD').where.not(amount: nil).find_in_batches(batch_size: 500) do |batch|
Spree::Price.transaction { batch.each { |p| p.update!(amount: (p.amount * 1.1).round(2)) } }
end"Customer sees the wrong price"
- Currency — what's
Spree::Current.currency/ the cart's currency? Does the variant have a base price in it? - Which list won?
variant.price_for(currency:, user:, quantity:)&.price_list— then check catalogs for that buyer (company node before customer group before channel default) and standalone lists byposition.Spree::Current.price_listsshows the standalone candidates for the request. - Status / window — draft lists never apply; scheduled lists only inside
starts_at..ends_at; a deactivated catalog's owned list goes dormant. - Quantity — volume rules and breaks only apply when the context has a quantity (cart lines do, listings don't).
- Cart vs PDP — line items keep the price they were priced at; the cart re-prices on changes that matter (items, address/market) through
Spree::Carts::Recalculate. Placed orders are money-frozen. - Tax display — gross vs net is a market/tax concern (
spree-taxes).
Gotchas
set_price(currency, amount)without the third argument wipescompare_at_amount.price_inreturns an unsaved blankSpree::Pricerather than nil — test.amountor.persisted?.- Read-modify-write trap:
?expand=variants.pricesreturns every price row — base, price-list and quantity-break rows, each withprice_list_id(null= base). Writingpricesback on a variant/product treats every entry as a base price (and drops currencies you leave out), so posting the expanded array back overwrites the shop price with a list or ladder amount, and a null-amount membership placeholder deletes a currency. Filter toprice_list_id === nullbefore sending; write list prices through/api/v3/admin/prices. (The dashboard's product form does this filtering; your own clients must too.) min_quantity > 1without aprice_list_idis invalid; percentage adjustments are only allowed on catalog-owned lists.- Removing a catalog's pricing (
price_list: null) or deleting the catalog soft-deletes the owned list — it never "releases" to match everyone. - The Store API product filter price range (
products.filters→price_range) is computed from base prices only — never price-list rows (a company's contract price, a draft or scheduled list) — and is left out entirely when the channel hides prices from the caller. Don't expect it to reflect a B2B buyer's list prices. - Price-list writes (
Spree::PriceLists::Update, including a catalog's inline list) silently drop variants from other stores. - Use promotions (
spree-promotions) for checkout-time discounts and coupon codes; use price lists when the reduced price must show on the product page.
Where to read further
node_modules/@spree/docs/dist/developer/core-concepts/pricing.mdnode_modules/@spree/docs/dist/developer/core-concepts/catalogs.mdnode_modules/@spree/docs/dist/user/how-to/volume-pricing.md,.../schedule-sale-pricing.md,.../wholesale-pricing.md- Source:
Spree::Price,Spree::PriceList,Spree::PriceRule,Spree::Pricing::Context,Spree::PricingProvider::Internalinspree_core - Related skills:
spree-catalog,spree-b2b,spree-taxes,spree-providers,spree-promotions

