spree/agent-skills

spree-order-totals

Use when the user is working with money on a Spree 6 cart or order — how total is built, the typed money rows (Spree::TaxLine, Spree::Discount, Spree::Fee), totals columns (itemtotal, discounttotal, deliverytotal, feetotal, includedtaxtotal vs additionaltax…

Voir la source

Mode d’emploi

  1. Copiez la commande et examinez le paquet avant de l’installer dans votre environnement agent.
  2. Consultez le document original pour les outils, identifiants et environnements requis.
  3. Décrivez les entrées et le livrable souhaité ; comparez la sortie aux sources.
npx skills add https://github.com/spree/agent-skills --skill spree-order-totals

Avant de commencer

  • L’installation n’inclut ni abonnement au modèle, ni quota API, ni accès aux fichiers.
  • Étoiles et installations reflètent l’activité, pas un test indépendant de qualité ou de sécurité.
Document Skill original

Rendu depuis le dépôt source en conservant titres, exemples, code, tableaux, liens et images.

Spree Order Totals

A cart's or order's total is assembled from typed money rows — there is no polymorphic Spree::Adjustment. Each kind of money has its own table, its own denormalized sum on the purchase, and a single workflow that keeps them consistent.

Coming from 5.x adjustments? See spree-upgrade-5-to-6 (spree:migrate_adjustments_to_typed_rows).

The formula

total = item_total + delivery_total + adjustment_total
adjustment_total = (all discount rows) + fee_total + additional_tax_total
Column (Cart & Order)What it holds
item_totalΣ line item amount (price × quantity), before discounts/tax
delivery_totalΣ fulfillment cost (the selected delivery rates)
discount_totalΣ promotion discount rows (negative) — manual discounts are not in it (see Gotchas)
fee_totalΣ fee rows (always ≥ 0), duties included
additional_tax_totalΣ tax lines with included: false — added on top
included_tax_totalΣ tax lines with included: true — already inside prices; informational
adjustment_totaldiscounts + fees + additional tax
totalWhat the customer pays
payment_totalCaptured payments minus refunds
tax_total (method)included_tax_total + additional_tax_total
amount_due (method)max(total − payment_total − applied store credit, 0)

Line items and fulfillments carry their own per-adjustable columns (discount_total, included_tax_total, additional_tax_total, adjustment_total, taxable_adjustment_total = discounts, non_taxable_adjustment_total = fees, pre_tax_amount) so per-line math and returns don't need to re-query rows.

Placed marketplace orders also carry commission_amount_total / commission_tax_total / commission_total — charged to the seller, never part of total (see spree-marketplace).

The typed rows

All three include Spree::TypedAdjustmentLine: dual cart_id / order_id owner (exactly one — read row.owner), label, amount (+ display_amount), metadata.

Model (prefix)Attaches to (exactly one…)KindsSignWritten by
Spree::TaxLine (tl_)line item, fulfillment, or fee— (included true/false, taxability_reason)≥ 0The tax provider only (see spree-taxes)
Spree::Discount (disc_)line item or fulfillmentpromotion, manual≤ 0Promotion adjuster; admin (manual)
Spree::Fee (fee_)line item, fulfillment, or neither (order-level)surcharge, handling, gift_wrap, cod, payment, duty (extensible)≥ 0Adjusters, integrations, admin
  • No order-level discount rows. An order-wide promotion or manual discount is spread across line items by largest-remainder over each line's discounted base — which is what makes a partial return refund the right share without guessing.
  • Rows snapshot their provenance: a discount keeps code, value, value_type (promotion FKs nullify on delete); a tax line keeps rate, label, provider_id, jurisdiction; a duty fee keeps its HS code / origin / rate in metadata. Never re-derive a placed order's money from today's catalog.
  • Fees are taxable by default (tax lines point at fee_id); duty fees are excluded from the taxable set.
  • Credits are never negative fees — create a discount.

Spree::Carts::RecalculateTotals — the single seam

Everything that changes money ends in this workflow (Spree.cart_recalculate_totals_workflow; order twin Spree.order_recalculate_totals_workflow = Spree::Orders::RecalculateTotals). cart.recalculate_totals! is the shortcut.

reset caches → total_quantity → item_total / delivery_total / payment_total
  → [unless money-frozen] regenerate rows:
       Spree.adjusters.each { |a| a.adjust(cart) }          # promotions, your fees/discounts
       run_hooks :set_tax_line_context                      # carts.recalculate_totals.set_tax_line_context
       cart.tax_provider.estimate(cart, **cart.tax_estimate_inputs, context:)
  → fold rows into per-line and purchase columns → total → one update_columns

Two-pass by design: adjusters persist the discounted base first, then tax is estimated on it.

What triggers it — writes, not steps. Item add/upsert/remove (via Spree::Carts::Recalculate, which also rebuilds delivery proposals and re-activates promotions), address or market change (recalculate_for_address_change! also re-prices items), delivery-rate selection, coupon/gift card/store credit apply/remove, payments, cart merge, and once more inside the completion lock — the charged total is never trusted from an earlier request. Reading a cart doesn't recalculate (except the Store API GET backfilling missing delivery proposals).

Every Store API write returns the full cart with fresh totals; storefronts never call a "recalculate" endpoint.

The promotion adjuster is winner-only

Spree::Adjusters::Promotion evaluates every eligible promotion on every recalculation and persists only the winner per competition group: line-level actions compete per line item, fulfillment actions per fulfillment, order-level actions order-wide (winner distributed across lines). Most negative amount wins; ties go to the newest action. Losing candidates simply aren't written, so they "reinstate" automatically when they become best. It also deletes any kind: 'promotion' row it didn't write this pass. Details: spree-promotions.

Money freeze after placement

For a placed order (completed?) — or when called with resum_only: true (seller-split child drafts) — the workflow skips row regeneration: no adjusters, no promotion competition, no tax estimation. It only re-sums the rows the order already has. Consequences:

  • Today's promotions and tax rates never rewrite last week's order.
  • Custom adjusters don't run on placed orders.
  • Post-placement money changes go through explicit services that write rows, then re-sum:
ChangeServiceAdmin API
Manual discount (flat/percent, one line or distributed)Spree::Orders::Discounts::Create/Update/DestroyPOST/PATCH/DELETE /api/v3/admin/orders/:id/discounts
FeeSpree::Orders::Fees::Create/Update/Destroy/api/v3/admin/orders/:id/fees
Items / attributesSpree::Orders::Update (items: [...]), Orders::UpdateItem, Orders::AddItemPATCH /api/v3/admin/orders/:id, /api/v3/admin/orders/:id/items
Tax lines— (provider-written)GET /api/v3/admin/orders/:id/tax_lines (read-only)
ruby
Spree::Orders::Discounts::Create.call(order: order, label: 'Price match', value: '15', value_type: 'flat')
Spree::Orders::Fees::Create.call(order: order, attributes: { label: 'Handling', kind: 'handling', amount: 2.5 })

Each of these services re-sums and then calls order.update_statuses!, because no event announces a moved total. A paid order that gains a fee reads partially_paid, and one cut below what was paid reads overcharged (amount_due floors at zero, so only the status says a refund is owed). A custom service that edits a placed order's money must make the same call. Orders::Update is all-or-nothing: if any item change in the request is refused, no line, quantity or address change is saved.

Only manual discounts are editable — touching a promotion row fails (422 via the API). Because tax is not re-estimated on a placed order, a manual discount does not reduce already-charged tax; handle tax corrections through your tax provider / a return.

Custom fees and discounts: adjusters

An adjuster owns a family of rows and is re-run on every (unfrozen) recalculation. Contract: subclass Spree::Adjusters::Base, implement idempotent update (write the rows that should exist, delete the ones that shouldn't). order is the cart (or draft order); order.fees / order.discounts attach to the right owner automatically.

ruby
# server/app/models/my_app/adjusters/gift_wrap.rb
module MyApp
  module Adjusters
    class GiftWrap < Spree::Adjusters::Base
      PRICE = BigDecimal('4.99')

      def update
        wrapped = order.line_items.select { |li| li.metadata['gift_wrap'].present? }

        order.fees.where(kind: 'gift_wrap').where.not(line_item_id: wrapped.map(&:id)).delete_all
        wrapped.each do |li|
          order.fees.find_or_initialize_by(kind: 'gift_wrap', line_item_id: li.id)
               .update!(label: 'Gift wrapping', amount: PRICE * li.quantity)
        end
      end
    end
  end
end
ruby
# server/config/initializers/spree.rb — MUST be after_initialize:
# core assigns Spree.adjusters = [Spree::Adjusters::Promotion] in its own after_initialize,
# which would wipe anything appended earlier.
Rails.application.config.after_initialize do
  Spree.adjusters << MyApp::Adjusters::GiftWrap
  Spree::Fee::KINDS << 'insurance' unless Spree::Fee::KINDS.include?('insurance') # custom fee kind (validated, not frozen)
end

Custom discounts from an adjuster must use kind: 'manual' (Spree::Discount::KINDS is frozen to promotion/manual) and attach to a line item or fulfillment. Don't write kind: 'promotion' rows — the promotion adjuster deletes rows it didn't create.

Fees written by an adjuster are taxed in the same pass (the provider runs after adjusters).

Duties

A customs duty is a Spree::Fee with kind: 'duty', written by a landed-cost integration (typically an adjuster). Rules:

  • Not in the default taxable set (purchase.taxable_items excludes duties) — import VAT, if any, is written by the duty provider itself as a tax line against the duty fee.
  • Snapshot the inputs (HS code, origin, rate) in the fee's metadata; never recompute a placed order's duty.
  • Variants carry hs_code, country_of_origin (manufacture, not ship-from), customs_description.

Rendering totals (Store API / SDK)

Money is a string ("135.60") plus a pre-formatted display_* twin ("$135.60"). Render display_*; if you must do math, use a decimal library or integer cents — never JS floats.

typescript
cart.display_item_total      // items
cart.display_discount_total  // negative
cart.display_delivery_total
cart.display_fee_total       // cart.fees[] for line-by-line (label, kind, display_amount)
cart.display_tax_total       // safe single tax line (included + additional)
cart.display_total
cart.display_amount_due      // after gift cards / store credit
  • Building your own subtotal? Add `additional_tax_total` only — never included_tax_total (it's inside the prices already). The #1 European-storefront bug.
  • List fees between subtotal and total; they're already in total, don't add them again. Show duty fees under their own label.
  • Store API cart.discounts / order.discounts is a per-applied-promotion summary (name, code, amount, display_amount, promotion_id), not the typed Spree::Discount rows; typed rows are on the Admin API (adminClient.orders.discounts).
  • Gift cards and store credit are payments, not discounts — they reduce amount_due, not total.
  • With hidden prices (gated B2B guests) money attributes serialize as null.

Gotchas

  • `discount_total` counts only `promotion` rows. Manual discounts (and custom adjuster discounts) are in adjustment_total and total but not in discount_total. If your summary shows discount_total + total, a manual discount looks like a missing amount — show item_total + delivery_total + fee_total + additional_tax_total − total as "Discounts", or list Spree::Discount rows.
  • Never write totals columns directly (update_column(:total, …)) — the next recalculation overwrites them. Write rows, then call recalculate_totals! / the order services.
  • Never create `TaxLine` rows by hand. They're replace-all per item by the provider; yours vanish on the next estimate.
  • Changing items unapplies gift cards and checkout store credit (Spree::Carts::Recalculate) — re-apply after edits. A plain totals recalculation (delivery, discounts, tax moving) keeps the gift card and resizes its payment to the new total instead.
  • An adjuster that raises aborts the whole recalculation (and the customer's add-to-cart). Keep external calls out of adjusters or rescue inside.
  • Don't read row.order during checkout — use row.owner.

Where to read further

  • node_modules/@spree/docs/dist/developer/core-concepts/order-totals.md, discounts.md, fees.md
  • node_modules/@spree/docs/dist/developer/how-to/custom-promotion.md — custom adjusters
  • Related skills: spree-taxes, spree-promotions, spree-checkout, spree-returns, spree-pricing
  • Source: spree/core/app/workflows/spree/carts/recalculate_totals.rb, spree/core/app/models/spree/adjusters/, spree/core/app/models/spree/{tax_line,discount,fee}.rb
du même dépôt

Autres Skills

Tous les Skills
spree
Communauté

spree-deployment

Use when deploying a Spree 6 application to production or scaling it — Docker image builds, Render/AWS/Railway/Kubernetes/VPS, required environment variables, Solid Queue (jobs in Puma vs a dedicated bin/jobs worker), Solid Cache, optional Sidekiq/Redis, the Mission Control /jobs dashboard, serving the React dashboard at /dashboard or from a static host, migrations on boot and spree:upgrade, S3/R2 storage, CDNHOST, Meilisearch, PostgreSQL/MySQL/SQLite, and observability (spreeopentelemetry, Sent

installations
1
GitHub Stars
10
Mis à jour
30 sept.
spree
Communauté

spree-multi-tenant

Use when building a SaaS / multi-tenant platform on Spree 6 with the Enterprise spreemultitenant gem — many isolated merchants on one deployment, each with its own stores, catalog, customers and orders. Covers licensing and install (spreeenterprise:install, spreemultitenant:install), Spree::Tenant and row-level tenantid isolation, SpreeMultiTenant::Base, withtenant / withouttenant, console tenant context, insertall/upsertall pitfalls, background jobs, Spree.rootdomain, cookie domain and host aut

installations
1
GitHub Stars
10
Mis à jour
30 sept.
spree
Communauté

spree-performance

Use when investigating or improving Spree 6 performance — slow cart writes, slow checkout, slow Store API product listings, N+1 queries in custom endpoints or serializers, search latency, Meilisearch indexing floods, image processing backlogs, Solid Queue/Sidekiq queue tuning, HTTP/CDN caching of the Store API, dashboard data-fetching, or tracing with OpenTelemetry. Common phrasings include "slow cart", "add to cart is slow", "tax provider called too often", "N+1 in Spree", "slow product listing

installations
1
GitHub Stars
10
Mis à jour
30 sept.
spree
Communauté

spree-pricing

Use when the user is working with Spree 6 pricing — variant prices per currency (pricein, amountin, setprice), compare-at/sale prices, multi-currency, price lists and price rules (market, channel, volume), catalog-owned price lists for B2B/wholesale, percentage price adjustments, quantity breaks / volume pricing, scheduled sales, the pricing context and resolution order, external pricing providers, bulk price updates, and EU Omnibus price history (priorprice). Common phrasings include "set price

installations
1
GitHub Stars
10
Mis à jour
30 sept.