Jak używać
- Skopiuj polecenie instalacji i sprawdź źródła przed instalacją we własnym środowisku agenta.
- W oryginale sprawdź wymagane narzędzia, dane dostępowe i obsługiwane środowiska.
- Opisz dane wejściowe i oczekiwany rezultat; porównaj wynik ze źródłami.
npx skills add https://github.com/spree/agent-skills --skill spree-order-totalsPrzed rozpoczęciem
- Instalacja nie obejmuje subskrypcji modelu, limitu API ani uprawnień do plików.
- Gwiazdki i instalacje pokazują aktywność, a nie niezależny test jakości lub bezpieczeństwa.
Treść z repozytorium z zachowaniem nagłówków, przykładów, kodu, tabel, linków i obrazów.
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

