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…

Ver código fuente

Cómo usar

  1. Copia el comando y revisa el paquete antes de instalarlo en tu entorno de agente.
  2. Consulta las herramientas, credenciales y entornos necesarios en el documento original.
  3. Describe las entradas y el resultado deseado; contrasta la salida con las fuentes.
npx skills add https://github.com/spree/agent-skills --skill spree-order-totals

Antes de empezar

  • La instalación no incluye suscripción al modelo, cuota API ni permisos sobre tus archivos.
  • Las estrellas e instalaciones reflejan actividad, no una prueba independiente de calidad o seguridad.
Documento original del Skill

Contenido del repositorio de origen con títulos, ejemplos, código, tablas, enlaces e imágenes preservados.

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
del mismo repositorio

Más Skills

Todos los Skills
spree
Comunidad

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

instalaciones
1
GitHub Stars
10
Actualizado
30 sept
spree
Comunidad

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

instalaciones
1
GitHub Stars
10
Actualizado
30 sept
spree
Comunidad

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

instalaciones
1
GitHub Stars
10
Actualizado
30 sept
spree
Comunidad

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

instalaciones
1
GitHub Stars
10
Actualizado
30 sept