skillZs
★ LIVE SKILL TAGS ★
>>> LIVE SKILLS INDEX <<<
* OPEN SOURCE *
NO LOGIN, NO TRACKING
※ REAL INSTALL DATA ※
← back to all skills
spree/agent-skills106 installs

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 (`item_total`, `discount_total`, `delivery_total`, `fee_total`, `included_tax_total` vs `additional_tax_total`, `adjustment_total`, `amount_due`), `Spree::Carts::RecalculateTotals`, custom adjusters (`Spree.adjusters`), fees and customs duties, manual discounts and fees on placed orders, or rendering an order summary. Common phrasings include "order total is wrong", "totals don't add up", "tax counted twice", "add a surcharge / handling fee / gift wrap fee", "add a custom fee", "where are adjustments", "Spree::Adjustment missing", "recalculate order totals", "edit a placed order's discount", "why did my discount disappear", "display_total", "money as string", "duties".

How do I install this agent skill?

npx skills add https://github.com/spree/agent-skills --skill spree-order-totals
view source ↗

Is this agent skill safe to install?

  • Gen Agent Trust Hubpass

    The skill provides technical documentation and reference material for handling order totals, taxes, and discounts in Spree 6. It contains no malicious code, remote dependencies, or security risks.

  • Socketpass

    No alerts

  • Snykwarn

    Risk: MEDIUM · 1 issue

What does this agent skill do?

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)
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)
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.

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

Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.

<a href="https://skillzs.dev/skills/spree/agent-skills/spree-order-totals">View spree-order-totals on skillZs</a>