spree/agent-skills

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 + priorprice), tax-inclusive pricing.

Ver código-fonte
Documento original do Skill

Renderizado do repositório de origem, preservando títulos, exemplos, código, tabelas, links e imagens.

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::*:

SubclassMatches when…
Spree::PriceRules::ChannelRuleThe order's channel is in a specified set of channels
Spree::PriceRules::CustomerGroupRuleThe customer is in a specified CustomerGroup
Spree::PriceRules::MarketRuleThe order's market is in a specified set of markets (5.4)
Spree::PriceRules::UserRuleA specific User is logged in
Spree::PriceRules::VolumeRuleThe 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 compareat-only changes are not tracked; recording requires `Spree::Config[:trackpricehistory]`). The `Spree::Price#priorprice 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:

ModeWhat's stored in Price.amountCustomer 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.
do mesmo repositório

Mais Skills

Todos os Skills
spree
Comunidade

spree-decorators

Use when the user wants to extend a Spree model, controller, helper, or service class without forking — add an association to Spree::Product, add a method to Spree::Order, override a validation, add a scope, prepend a beforeaction, hook into create. Common phrasings include "add brand to product", "decorate Spree::X", "ProductDecorator", "OrderDecorator", "Moduleprepend", "spree:modeldecorator", "extend an existing Spree model", "add a method to Spree::Order", "override Spree behavior", "monkey patch Spree". Provides the decorator pattern, the generator, the prepended(base) idiom, and the gotchas. Mentions when NOT to decorate — events for after-save side effects, dependencies for service swaps, the resource generator for whole new models.

instalações
1
GitHub Stars
8
Atualizado
6 de jul.
spree
Comunidade

spree-deployment

Use when the user is deploying a Spree application to production — Heroku, Render, Fly, AWS, K8s, plain Docker. Covers required environment variables, Sidekiq setup, the release-phase command (rake spree:upgrade), ActiveStorage backend config, asset precompile, the docker-entrypoint behavior. Common phrasings include "deploy Spree", "Heroku deploy", "Render deploy", "Spree on Kubernetes", "Spree environment variables", "Spree release command", "Procfile", "Sidekiq deployment", "S3 setup", "Cloudflare R2", "asset precompile failed". Spree-specific bits only — generic Rails deployment is out of scope.

instalações
1
GitHub Stars
8
Atualizado
6 de jul.
spree
Comunidade

spree-extensions

Use when the user wants to install a specific third-party Spree gem (Stripe, Adyen, PayPal, i18n, search, social login, etc.), build their own Spree extension to share across apps, or swap a core Spree service via Spree.dependencies. Common phrasings include "add Stripe", "install spreeX", "what payment gateways", "create a Spree extension", "build a gem for Spree", "spreedevtools", "Spree.dependencies", "service swap". For deciding which customization pattern to use, see the spree-customization skill first — extensions are the last two rows of its decision tree and most single-app work doesn't need one.

instalações
1
GitHub Stars
8
Atualizado
6 de jul.
spree
Comunidade

spree-performance

Use when the user is investigating or improving Spree performance — slow product listings, slow cart updates, search latency, image processing bottlenecks, Sidekiq queue tuning, N+1 in admin pages, cache invalidation strategies. Common phrasings include "slow PDP", "slow cart", "N+1 queries in Spree", "Sidekiq queue backlog", "search slow", "Meilisearch tuning", "image processing slow", "Spree cache". Provides the Spree-specific performance hotspots and the tools to address them.

instalações
1
GitHub Stars
8
Atualizado
6 de jul.