Mode d’emploi
- Copiez la commande et examinez le paquet avant de l’installer dans votre environnement agent.
- Consultez le document original pour les outils, identifiants et environnements requis.
- 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-totalsAvant 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é.
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? Seespree-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_total | discounts + fees + additional tax |
total | What the customer pays |
payment_total | Captured 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…) | Kinds | Sign | Written by |
|---|---|---|---|---|
Spree::TaxLine (tl_) | line item, fulfillment, or fee | — (included true/false, taxability_reason) | ≥ 0 | The tax provider only (see spree-taxes) |
Spree::Discount (disc_) | line item or fulfillment | promotion, manual | ≤ 0 | Promotion adjuster; admin (manual) |
Spree::Fee (fee_) | line item, fulfillment, or neither (order-level) | surcharge, handling, gift_wrap, cod, payment, duty (extensible) | ≥ 0 | Adjusters, 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 keepsrate,label,provider_id, jurisdiction; a duty fee keeps its HS code / origin / rate inmetadata. Never re-derive a placed order's money from today's catalog. - Fees are taxable by default (tax lines point at
fee_id);dutyfees 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_columnsTwo-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:
| Change | Service | Admin API |
|---|---|---|
| Manual discount (flat/percent, one line or distributed) | Spree::Orders::Discounts::Create/Update/Destroy | POST/PATCH/DELETE /api/v3/admin/orders/:id/discounts |
| Fee | Spree::Orders::Fees::Create/Update/Destroy | /api/v3/admin/orders/:id/fees |
| Items / attributes | Spree::Orders::Update (items: [...]), Orders::UpdateItem, Orders::AddItem | PATCH /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) |
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.
# 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# 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)
endCustom 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_itemsexcludes 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.
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. Showdutyfees under their own label. - Store API
cart.discounts/order.discountsis a per-applied-promotion summary (name,code,amount,display_amount,promotion_id), not the typedSpree::Discountrows; typed rows are on the Admin API (adminClient.orders.discounts). - Gift cards and store credit are payments, not discounts — they reduce
amount_due, nottotal. - 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_totalandtotalbut not indiscount_total. If your summary showsdiscount_total+total, a manual discount looks like a missing amount — showitem_total + delivery_total + fee_total + additional_tax_total − totalas "Discounts", or listSpree::Discountrows. - Never write totals columns directly (
update_column(:total, …)) — the next recalculation overwrites them. Write rows, then callrecalculate_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.orderduring checkout — userow.owner.
Where to read further
node_modules/@spree/docs/dist/developer/core-concepts/order-totals.md,discounts.md,fees.mdnode_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

