docs/plans/6.0-split-adjustments.md
Status: Draft
Target: Spree 6.0
Depends on: 6.0-cart-order-split.md (owner semantics — typed lines are written during the cart phase and copied on completion), 6.0-fulfillment-and-delivery.md Phase 1 (tables are born with fulfillment_id, never migrated from shipment_id). Consumed by: 6.0-tax-provider.md (providers write TaxLine), 6.0-multi-vendor-marketplace.md (typed lines re-home at order split), 6.0-returns-exchanges-claims.md, 6.0-normalize-state-to-status.md.
Author: Damian + Claude
Last updated: 2026-07-27
Replace the polymorphic Spree::Adjustment model with three typed models: TaxLine, Discount, and Fee. Each has concrete foreign keys to its source and its adjustable, enabling DB-level integrity, SQL joins, indexed queries, and structural (not opt-in) eager loading.
The primary rationale is data modeling: today one table represents six different things — additional tax, included tax, competing promotions, shipping discounts, manual admin adjustments (which can be positive surcharges), and custom extension source types — and ~a dozen Order methods dispatch on source_type/eligible string matching to tell them apart. Every major platform (the hosted market leader, OSS platform A, OSS platform B, OSS platform C) models these as distinct typed structures; none uses a generic polymorphic adjustments table. OSS platform A is near-literal precedent for this design (order_line_item_tax_line, order_line_item_adjustment tables with concrete FKs).
The Spree.adjusters extensibility pattern is preserved for discounts and fees. Tax writing moves to the TaxProvider seam (see 6.0-tax-provider.md) — this plan provides the table providers write to.
The same spree_adjustments row represents:
ReturnAuthorization rows — no longer written)Scopes like tax, promotion, shipping, price filter by string-matching source_type or adjustable_type, and ~12 Order reader methods (order_level_promo_total, shipping_discount, valid_promotion_ids, cart_promo_total, has_free_shipping?, …) dispatch on class-name strings plus the eligible flag. The record's meaning is not knowable from its shape.
belongs_to :source, polymorphic: true and belongs_to :adjustable, polymorphic: true mean: no DB-level referential integrity, no SQL JOINs from line items to their adjustments, and eager loading that works only where a call site remembers to preload (Rails can preload polymorphic associations, and three call sites do — but the recalculation path in Adjuster::Promotion and Adjuster::Tax doesn't, so every recalc lazy-loads source and source.promotion per adjustment row). With typed tables the fast path is the default path, not a per-call-site opt-in.
Promotion competition is resolved by writing all candidate adjustments, then flipping losers with update_all(eligible: false) and re-deriving eligibility on every recalculation (Adjuster::Promotion, Adjustment#update!). A dozen downstream readers must remember to filter .eligible. No competitor persists losing candidates — the hosted leader's engine discards unchosen candidates, OSS platform B's DB unique constraint makes them unstorable, OSS platform A and OSS platform C recompute-and-replace.
The open/closed state machine is today the only mechanism protecting money on completed orders: Adjustment#update! early-returns when closed?, while OrderUpdater recalculates unconditionally — order completion does not lock recalculation. And the freeze leaks: TaxRate.adjust destroy-and-recreates tax adjustments even on completed orders when a line item changes. Per-row state is the wrong altitude — every competitor enforces immutability at the order level (OSS platform B gates recalculation by order status; the hosted market leader keeps original*/current* snapshots; OSS platform A versions rows). The replacement is an order-level recalculation freeze, which must exist before the state machine is removed (see Resolved Question 7).
Every developer or integration arriving from the hosted market leader, OSS platform A, OSS platform B, or OSS platform C must first learn source_type/adjustable_type/eligible/state semantics that exist nowhere else. The v3 API currently exposes a mixed adjustments array that forces clients to re-implement the same string dispatch.
| What | Where |
|---|---|
spree_adjustments table | 13 columns |
Spree::Adjustment model | 131 lines, 20 scopes, state machine |
Adjustable::AdjustmentsUpdater | Two-pass orchestration (non-tax → persist taxable totals → tax) |
| Adjusters | Adjuster::Tax, Adjuster::Promotion registered; Adjuster::Base abstract. (PromotionAccumulator is dead code — zero callers; delete freely.) |
AdjustmentSource concern | TaxRate + 3 PromotionActions, incl. deleted-source handling |
| Order, LineItem, Shipment | has_many :adjustments + 6 denormalized columns each: adjustment_total, promo_total, included_tax_total, additional_tax_total, taxable_adjustment_total, non_taxable_adjustment_total; plus pre_tax_amount on line items/shipments |
| Core consumers | ~166 references across ~30 files: promotion handlers (usage limits count eligible adjustments), coupon handlers, OrderMerger, Cart::Empty, refund calculators (DefaultRefundAmount reads order-level eligible rows), CanCanCan permission sets |
| Admin API v3 | Read-only /admin/orders/:id/adjustments endpoints, Admin::AdjustmentSerializer, ?expand=adjustments on order/line_item/fulfillment serializers |
| SDK / SPA | admin-sdk orders.adjustments resource, generated TS/Zod types, dashboard order page |
| Legacy admin | Full CRUD + open/close toggle on manual adjustments, order summary renders unknown source types |
| Emails | _purchased_items_table partials iterate .eligible adjustments with source_type checks |
| Tests / docs | ~41 core + 14 api + 10 admin spec files; adjustments.mdx, custom-promotion.mdx |
Findings from the 2026-07-27 competitor review (the hosted market leader Admin API, OSS platform A source, OSS platform B source, OSS platform C source):
| Concern | Spree 6.0 | The hosted market leader | OSS platform A | OSS platform B | OSS platform C |
|---|---|---|---|---|---|
| Tax | Typed rows: amount + rate + nullable tax_rate_id + provider_id | Embedded snapshots (title, rate), no FK | Typed tables; rate + nullable tax_rate_id + provider_id + data | No tax rows; net/gross columns | Embedded JSON (rate only) |
| Discounts | Typed rows, promo FKs + code snapshot | Application (intent) + per-line allocation | Typed adjustment rows, code + promotion_id | Typed rows, SET_NULL FKs + snapshots | Embedded JSON, string source |
| Fees | First-class taxable Fee, line/fulfillment/order scoped | None merchant-facing (Duty/AdditionalFee are platform-only, taxed) | None (adjustments are CHECK >= 0 discounts) | None | Surcharge table, taxed, order-level only |
| Order-level discounts | Prorated to lines | Prorated to lines (+ intent record) | Prorated to lines | One order row + prorated totals | Prorated to lines (largest-remainder) |
| Losers persisted | No | No | No (stacks all valid) | No (DB constraint) | No (replace on recompute) |
| Per-row state | None | None | None | None | None |
Validated by all four: typed structures over polymorphic, winner-only persistence, no per-row state machine, order-level discounts prorated to lines. Deliberate Spree divergences: we persist tax amounts in queryable rows (OSS platform A/OSS platform C store rate only and derive — SQL-aggregable totals are the point of this plan), and Fee is line-item-granular (only OSS platform C has a fee primitive at all, and it's order-level only) — a genuine differentiator; the hosted market leader staff confirm apps on their platform cannot add charges at all.
Three typed models replace one polymorphic model:
Spree::TaxLine — tax charges. Nullable tax_rate_id (set by the Internal tax provider, nil for external providers), plus self-describing snapshot columns: rate (decimal), label, provider_id (string), data (JSON, provider payloads — jurisdiction breakdowns, facilitator-liability info). Rows stay meaningful after a TaxRate is deleted or an external provider computed them.Spree::Discount — promotion and manual discounts. Nullable promotion_action_id/promotion_id FKs (dependent: :nullify from the promotion side) plus provenance snapshots: code, value + value_type (the input, e.g. 10%), kind. Amount is enforced <= 0 (validation + DB CHECK), and adjusters clamp so combined discounts never push an adjustable's net below zero.Spree::Fee — extensible charges (surcharge, handling, gift wrap, COD, payment fees). kind required, amount >= 0. Fees are taxable: TaxLine accepts fee_id as an adjustable (EU VAT applies to surcharges — both competitor fee primitives, the hosted leader's Duty/AdditionalFee and OSS platform C's Surcharge, carry tax lines).Concrete adjustable FKs, fulfillment vocabulary. TaxLine and Discount belong to exactly one of line_item / fulfillment (TaxLine additionally: fee). Tables are born with fulfillment_id per 6.0-fulfillment-and-delivery.md — no new shipment references. Fee may additionally be order-level (both FKs nil) — the canonical fee use cases (payment surcharge, COD, small-order fee) belong to no line; at vendor split, order-level fees prorate across child orders by item totals (coordinate with 6.0-multi-vendor-marketplace.md, which currently assumes strict line/fulfillment attachment).
Owner is cart-or-order, not order-only. Discounts and tax estimates are computed during the cart phase (6.0-cart-order-split.md). Typed lines carry nullable cart_id / order_id with an exactly-one validation, mirroring the adjustable XOR. Spree::Carts::Complete copies typed lines to the order alongside line items (copy-on-completion), re-pointing line_item_id to the new order line items. New code derives the owner via the adjustable (line_item.owner), never assumes order.
Tax writing belongs to the TaxProvider, not TaxRate. TaxRate.adjust is removed by 6.0-tax-provider.md; this plan must not reimplement it. Spree.tax_provider.estimate(owner, items) writes TaxLines with replace-all set semantics (delete the target's stale lines, write fresh ones) — this is also what prevents stale tax after an address change. TaxRate becomes pure configuration read by the Internal provider. Spree.adjusters covers discounts and fees only.
Remove the state machine — after the order-level freeze exists. No open/closed states, no status column on the new tables. Precondition (hard, sequenced before Phase 3): OrderUpdater refuses adjustment recalculation on completed orders; post-placement changes go through explicit admin actions that write typed rows directly. The admin open/close toggle is dropped with that CRUD replacement. See Resolved Question 7.
Remove the eligible flag; 6.0 ships winner-only, stacking-ready — stacking itself lands in 6.1 (decision 2026-07-27, retargeted same day: stacking is purely additive, so it doesn't need the 6.0 breaking window and 6.0 is already loaded). What 6.0 guarantees so 6.1 is cheap: the schema permits multiple Discount rows per adjustable (already true), clamping + prorate-over-the-remaining-discounted-base ship with order-level distribution (Resolved Question 1), and the winner-only policy is isolated in one adjuster seam (candidate computation returns all candidates; selection is a single method). 6.1 adds hosted-leader-style Promotion#combines_with flags (per class: item/order/shipping — combining candidates all persist, non-combining compete winner-takes-all, default off) plus dashboard UI and API fields — no migration, no adjuster rewrite. Only applied discounts are persisted; losers are recomputed from candidates on every recalculation, so "reinstatement" needs no stored rows and no eligible flag. Promotion usage-limit accounting (Promotion#credits_count, per-user checks) is re-specified to count Discount rows / distinct orders.
Keep Spree.adjusters with a new base class Spree::Adjusters::Base (the old Spree::Adjustable::Adjuster::* namespace is removed). Custom adjusters write Fee or Discount rows.
Denormalized totals stay — all of them: adjustment_total, promo_total, included_tax_total, additional_tax_total, taxable_adjustment_total, non_taxable_adjustment_total on Order/LineItem/Fulfillment, pre_tax_amount on LineItem/Fulfillment, plus new fee_total on Order. Recomputed from typed tables. The two-pass ordering is preserved: discount/fee adjusters run first and persist the discounted taxable base, then the tax provider estimates on discounted amounts (pre_tax_amount is maintained during estimate).
All models include Spree::Metadata per house convention.
class Spree::TaxLine < Spree.base_class
include Spree::Metadata
has_prefix_id :tl
# Owner — exactly one (copied cart → order at completion)
belongs_to :order, class_name: 'Spree::Order', optional: true, inverse_of: :tax_lines
belongs_to :cart, class_name: 'Spree::Cart', optional: true, inverse_of: :tax_lines
# Source — nil for externally-computed tax; snapshots below keep the row self-describing
belongs_to :tax_rate, class_name: 'Spree::TaxRate', optional: true
# Adjustable — exactly one
belongs_to :line_item, class_name: 'Spree::LineItem', optional: true
belongs_to :fulfillment, class_name: 'Spree::Fulfillment', optional: true
belongs_to :fee, class_name: 'Spree::Fee', optional: true
validates :amount, numericality: true
validates :rate, numericality: { greater_than_or_equal_to: 0 }
validates :label, presence: true
validate :exactly_one_owner
validate :exactly_one_adjustable
# Tax included in price vs additional. Per-row (mixed regimes on one order),
# richer than the hosted leader's order-level taxes_included.
attribute :included, :boolean, default: false
scope :included_in_price, -> { where(included: true) }
scope :additional, -> { where(included: false) }
scope :for_line_items, -> { where.not(line_item_id: nil) }
scope :for_fulfillments, -> { where.not(fulfillment_id: nil) }
scope :for_fees, -> { where.not(fee_id: nil) }
end
Columns beyond the FKs: amount, rate (decimal snapshot — enables rate-grouped tax reports and receipts even after TaxRate deletion), label, included, provider_id (string, e.g. 'internal', 'avalara'), data (JSON — provider payloads such as jurisdiction breakdowns; jsonb where supported).
Write contract: providers use replace-all set semantics per estimate — delete the target items' existing TaxLines, insert fresh ones. No unique-index-based upsert identity (it only works for Internal-provider lines); plain indexes on the FKs.
class Spree::Discount < Spree.base_class
include Spree::Metadata
has_prefix_id :disc
belongs_to :order, class_name: 'Spree::Order', optional: true, inverse_of: :discounts
belongs_to :cart, class_name: 'Spree::Cart', optional: true, inverse_of: :discounts
# Source — nullified on promotion deletion; code/value snapshots keep history meaningful
belongs_to :promotion_action, class_name: 'Spree::PromotionAction', optional: true
belongs_to :promotion, class_name: 'Spree::Promotion', optional: true
# Adjustable — exactly one (order-level discounts are distributed to line items)
belongs_to :line_item, class_name: 'Spree::LineItem', optional: true
belongs_to :fulfillment, class_name: 'Spree::Fulfillment', optional: true
validates :amount, numericality: { less_than_or_equal_to: 0 } # + DB CHECK constraint
validates :label, presence: true
validate :exactly_one_owner
validate :exactly_one_adjustable
scope :for_line_items, -> { where.not(line_item_id: nil) }
scope :for_fulfillments, -> { where.not(fulfillment_id: nil) }
scope :manual, -> { where(promotion_action_id: nil) }
end
Columns beyond the FKs: amount (≤ 0), label, kind ('promotion', 'manual', 'loyalty', …), and provenance snapshots: code (the coupon code — "how much did SUMMER10 move" becomes one indexed query), value + value_type ('percent' / 'flat' — the input, for "10% off" display; OSS platform B precedent). Orders outlive promotions: FKs are dependent: :nullify from the promotion side, snapshots keep the rows meaningful.
FreeShipping writes a Discount on the fulfillment (amount = -cost) and persists the row even at zero amount — Order#has_free_shipping? tests row existence; the zero-skip pattern used elsewhere does not apply to this action.
class Spree::Fee < Spree.base_class
include Spree::Metadata
has_prefix_id :fee
belongs_to :order, class_name: 'Spree::Order', optional: true, inverse_of: :fees
belongs_to :cart, class_name: 'Spree::Cart', optional: true, inverse_of: :fees
# Adjustable — optional: both nil ⇒ order-level fee (payment surcharge, COD, small-order fee)
belongs_to :line_item, class_name: 'Spree::LineItem', optional: true
belongs_to :fulfillment, class_name: 'Spree::Fulfillment', optional: true
has_many :tax_lines, class_name: 'Spree::TaxLine', dependent: :destroy
validates :amount, numericality: { greater_than_or_equal_to: 0 }
validates :label, presence: true
validates :kind, presence: true # 'surcharge', 'handling', 'gift_wrap', 'payment', ...
validate :exactly_one_owner
end
Fees participate in tax: the tax provider estimates over line items, fulfillments, and fees, writing TaxLine(fee_id: …) rows. Ad-hoc credits are not negative fees — they are manual Discounts (unlike OSS platform C's Surcharge, which overloads both directions).
class Spree::Order < Spree.base_class
has_many :tax_lines, class_name: 'Spree::TaxLine', dependent: :destroy
has_many :discounts, class_name: 'Spree::Discount', dependent: :destroy
has_many :fees, class_name: 'Spree::Fee', dependent: :destroy
has_many :line_item_tax_lines, through: :line_items, source: :tax_lines
has_many :line_item_discounts, through: :line_items, source: :discounts
has_many :fulfillment_tax_lines, through: :fulfillments, source: :tax_lines
has_many :fulfillment_discounts, through: :fulfillments, source: :discounts
end
# Spree::Cart gets the same three has_many. LineItem and Fulfillment get
# tax_lines / discounts / fees (dependent: :destroy).
Adjustable::AdjustmentsUpdater and Adjustable::Adjuster::* are replaced. OrderUpdater (and its cart counterpart) runs:
def recalculate_adjustments
return if order.completed? # the order-level freeze — see Resolved Question 7
run_discount_and_fee_adjusters # Spree.adjusters — writes Discount/Fee rows,
# persists discounted taxable base + fee_total
Spree.tax_provider.estimate(order, taxable_items) # writes TaxLine rows (replace-all)
update_totals_from_typed_tables # single grouped SUMs per table — no N+1
end
Totals become set-based queries (order.tax_lines.for_line_items.group(:included).sum(:amount), order.discounts.sum(:amount), order.fees.sum(:amount)), preserving the discount-before-tax ordering and maintaining taxable_adjustment_total / non_taxable_adjustment_total / pre_tax_amount for the refund calculators that consume them.
Consumers that today string-match source_type/eligible are rewritten against typed associations: Promotion#credits_count counts distinct orders with that promotion's discounts; PromotionHandler::Coupon and CouponCodesHandler query order.discounts; CreateItemAdjustments' cross-adjustable negative-total guard reads order.discounts.sum(:amount); DefaultRefundAmount reads the line item's own discounts (proportional distribution makes the order-level weighting obsolete — see Resolved Question 1); OrderMerger re-points owner + line_item FKs; Cart::Empty deletes the cart's typed rows.
6.0 behavior is winner-takes-all per adjustable — today's semantics. The policy is deliberately isolated: candidate computation returns all candidates and selection is one method, so 6.1's combines_with partition (combining candidates all apply, clamped and prorated over the remaining discounted base; non-combining compete) replaces only that method:
class Spree::Adjusters::Promotion < Spree::Adjusters::Base
def update
candidates = compute_all_promo_amounts(adjustable) # in memory, sources preloaded
best = candidates.min_by { |c| c[:amount] } # most negative wins
return adjustable.discounts.where(kind: 'promotion').destroy_all unless best
adjustable.discounts.where(kind: 'promotion')
.where.not(promotion_action_id: best[:promotion_action].id).destroy_all
adjustable.discounts.find_or_initialize_by(
promotion_action: best[:promotion_action], kind: 'promotion'
).update!(
amount: clamp(best[:amount], adjustable), # net can never go below zero
label: best[:label], code: best[:code],
value: best[:value], value_type: best[:value_type],
promotion: best[:promotion_action].promotion
)
end
end
Promotion action classes move to Spree::PromotionActions::* per decisions.md 2026-03-16; CreateItemAdjustments → CreateItemDiscounts as part of that rename wave.
# config/initializers/spree.rb
Spree.adjusters << MyApp::Adjusters::GiftWrap
class MyApp::Adjusters::GiftWrap < Spree::Adjusters::Base
def update
return unless adjustable.respond_to?(:gift_wrap?) && adjustable.gift_wrap?
adjustable.fees.find_or_initialize_by(kind: 'gift_wrap').update!(
amount: 5.99, label: 'Gift wrapping'
)
# No manual totals bookkeeping — OrderUpdater sums typed tables,
# and the tax provider taxes the fee in the same recalculation pass.
end
end
Custom discounts follow the same pattern writing Discount (kind: 'loyalty', etc.). The legacy custom-source_type seam (unknown sources rendered by the admin order summary) maps to Fee#kind / Discount#kind grouping.
The legacy "manual adjustment with a label" splits by sign, keeping both directions of today's capability:
Discount(kind: 'manual'). The admin targets either a specific line item (the damaged item) or the whole order — order-level manual discounts distribute proportionally to line items at creation time using the same largest-remainder rule as order-level promotions (Resolved Question 1), so vendor splitting and per-line refund math keep working.Fee — order-level or attached to a line/fulfillment. Taxed by the provider like any fee.On paid orders, decreasing the total creates an outstanding credit — the recommended tool there is a partial Refund (see 6.0-returns-exchanges-claims.md); the manual-discount path remains available through the explicit post-placement edit flow (Resolved Question 7) for pre-capture corrections and draft/backoffice orders.
Typed rows are managed through the Admin API v3, not just read:
| Resource | Surface | Writes |
|---|---|---|
/admin/orders/:order_id/tax_lines | index, get | Read-only. Tax is provider-computed; edits happen by changing the order (address, items, fees) which re-runs estimate. No hand-edited tax rows (no competitor allows this either). |
/admin/orders/:order_id/discounts | full CRUD | Manual discounts only (kind: 'manual'). Promotion-sourced rows are read-only — the promotion engine owns them and would overwrite edits on recalculation (attempts return 422). |
/admin/orders/:order_id/fees | full CRUD | Manual fees. Adjuster-written fees are recomputed on recalculation and should not be hand-edited. |
Conventions per 6.0-admin-api.md: flat params, prefixed IDs, secret-key scopes (read_orders/write_orders) or JWT + CanCanCan (abilities granted on the three typed models replace today's Spree::Adjustment grants). Mutations run the order recalculation (totals, tax re-estimate on the changed base) — these endpoints are the explicit admin edit path from Resolved Question 7, so they work on completed orders where blind recalculation does not.
client.orders.taxLines.{list,get}, client.orders.discounts.{list,get,create,update,delete}, client.orders.fees.{list,get,create,update,delete}, with generated TS/Zod types replacing the orders.adjustments resource.value/value_type, target picker: whole order / specific line item), <Can>-gated, forms per the dashboard conventions (RHF + Zod, mapSpreeErrorsToForm, i18n keys in all locale files). This supersedes the legacy admin's generic adjustment form and open/close toggle.6.0-cart-order-split.md — owner semantics + Spree::Carts::Complete (gating dependency)6.0-fulfillment-and-delivery.md Phase 1 + 6.0-normalize-state-to-status.md — vocabulary, so the new tables are born correct6.0-tax-provider.md — providers become the sole TaxLine writers (never implement a "TaxRate.adjust writes TaxLines" step; at most a short-lived shim inside the Internal provider)6.0-returns-exchanges-claims.md, then 6.0-multi-vendor-marketplace.md order splitting (consumes typed lines)OrderUpdater (refuse adjustment recalculation on completed orders), with explicit admin edit paths (manual Fee/Discount CRUD) as the sanctioned post-placement mutation route. Built and tested before any state-machine removal.Key schema points (full migration authored at implementation time):
spree_tax_lines: cart_id/order_id (nullable, XOR), tax_rate_id (nullable), line_item_id/fulfillment_id/fee_id (nullable, XOR), amount + rate decimals (null: false), label null: false, included boolean null: false, provider_id string, data JSON (jsonb-guarded), metadata JSON. Plain FK indexes; no unique upsert identity.spree_discounts: owner XOR FKs, promotion_action_id/promotion_id (nullable), line_item_id/fulfillment_id (XOR), amount null: false + CHECK (amount <= 0), label null: false, kind, code, value, value_type, metadata JSON. Index on code.spree_fees: owner XOR FKs, line_item_id/fulfillment_id (both nullable — both nil = order-level), amount null: false + CHECK (amount >= 0), label null: false, kind null: false, metadata JSON. Index on [order_id, kind].spree_orders.fee_total column.For each spree_adjustments row:
| Source | Destination |
|---|---|
Spree::TaxRate | TaxLine (copy rate from the TaxRate; provider_id: 'internal') |
Spree::PromotionAction, eligible | Discount (kind 'promotion', snapshot code/value from the promotion) |
Spree::PromotionAction, eligible: false | skip (losers are not migrated) |
| nil source, amount < 0 | Discount (kind 'manual') |
| nil source, amount ≥ 0 | Fee (kind 'surcharge', order-level) |
Spree::ReturnAuthorization | skip — legacy rows; their orders are marked recalculation-frozen (totals no longer reproducible from typed tables) |
| unknown custom source | Fee (kind derived from source class name; original class recorded in metadata) |
Order-level rows (adjustable_type == 'Spree::Order'): promotion and manual discounts distribute proportionally to line items (largest-remainder — see Resolved Question 1); manual surcharges become order-level Fees as-is.
Upgrade path for existing orders:
fee_total land as normal migrations (no locks on spree_adjustments); the backfill runs separately as the rake task — batched by order, idempotent, resumable — never inside a migration. Stores upgrade, boot, then backfill online.promo_total, tax totals). Orders that don't reconcile (pre-existing data drift, skipped RA rows) are logged and marked recalculation-frozen rather than force-balanced.spree_adjustments stays intact and read-only through all of 6.0 as the audit + rollback reference (typed tables can be dropped and re-derived by re-running the task). The 6.1 table drop is the point of no return.Spree::Adjusters::Base; remove Adjustment, AdjustmentSource, Adjustable::AdjustmentsUpdater, Adjustable::Adjuster::*, PromotionAccumulator (dead), CalculatedAdjustments usage for adjustmentsOrderUpdater typed recalculation with freeze guard + two-pass ordering; cart counterpartPromotionActions::* write Discounts (winner-only, clamped); usage-limit accounting, coupon handlers, CouponCodesHandler, OrderPromotion#amount, FreeShipping zero-amount semantics, CreateItemAdjustments negative-total guardTaxRate keeps no write pathorder_level_promo_total, shipping_discount, valid_promotion_ids, cart_promo_total, has_free_shipping?, fully_discounted?, taxable_amount, …); OrderMerger, Cart::Empty, refund calculatorsTaxLine/Discount/Fee replace Spree::Adjustmentdocs/developer/core-concepts/adjustments.mdx, custom-promotion.mdx/admin/orders/:id/{tax_lines,discounts,fees} endpoints replace /adjustments — tax_lines read-only, discounts/fees full CRUD for manual rows (see "Admin management surface"); typed serializers; ?expand= updates on order/line_item/fulfillment serializers. Store API is already insulated (never exposed Adjustment rows)orders.adjustments → orders.{taxLines,discounts,fees} resources; dashboard order page typed panels + add-fee/add-discount forms. Admin SDK is unreleased — break cleanly, no bridgespree/admin is removed at 6.0 (React dashboard only); its adjustment CRUD/toggle/summary views are deleted surface, counted in blast radius but not rebuilt. The dashboard order page (above) is the admin UI_purchased_items_table partials iterate typed associationsPublishable resolves V3 serializers)spree_adjustments, remove legacy scopes/aliases and the migration rake tasksource_type string matching. New fee/discount types should be designed for the typed model.TaxRate.adjust or AdjustmentSource. The tax write path moves to the provider seam.line_item.owner, not line_item.order (cart/order split constraint).shipment references — fulfillment vocabulary (fulfillment plan constraint).taxable_adjustment_total / non_taxable_adjustment_total and pre_tax_amount; they're the performance optimization.Order-level discounts are distributed proportionally across line items at application time — no order-level Discount rows. Matches the hosted market leader/OSS platform A/OSS platform C. Math (specified now because refunds will depend on it): largest-remainder distribution so shares sum exactly to the promotion amount (OSS platform C's prorate()); the proration base is each line's remaining discounted amount (already-applied discounts subtracted — OSS platform A's stacking guard); weights are frozen at placement so partial cancellations don't redistribute discount money between lines (OSS platform C issue #4811). The order-level view is reconstructible via GROUP BY promotion_action_id — meaningful because code/value are snapshotted on every row.
Return credits are not Discounts — returns flow through the Refund model (6.0-returns-exchanges-claims.md). Legacy ReturnAuthorization adjustments are skipped at migration and their orders marked recalculation-frozen.
fee_total on Order. Order total formula: total = item_total + delivery_total + fee_total + additional_tax_total + promo_total (delivery_total per the fulfillment plan's rename).
Fees are taxable via TaxLine#fee_id. EU VAT applies to surcharges/gift wrap/handling; both competitor fee primitives carry tax lines. Fee tax stays itemized in the same reporting pipeline.
Order-level fees are legal (both adjustable FKs nil). At vendor split they prorate across child orders by item totals.
Promotion stacking targets 6.1; 6.0 ships winner-only, stacking-ready. Stacking (Promotion#combines_with, the hosted leader's model; OSS platform A/OSS platform C stack-with-caps validate the mechanics) is purely additive — no schema change (multiple Discount rows per adjustable are already legal), no breaking window needed. 6.0's obligations: clamping + remaining-base proration land with order-level distribution, and winner-only selection stays isolated in one adjuster method so 6.1 slots in the combines_with partition without touching persistence.
State machine replacement = order-level freeze. OrderUpdater refuses adjustment recalculation on completed orders (OSS platform B's status-gating model); post-placement changes go through explicit admin actions writing typed rows. The open/close toggle is dropped with the manual-adjustment CRUD replacement. The freeze ships before the state machine is removed. Deleted sources need no special handling — nullify + snapshots keep rows valid.
Tax write path is provider-owned (6.0-tax-provider.md). Replace-all set semantics per estimate; Spree.adjusters covers discounts/fees only.
Manual admin adjustments map to typed rows: negative → Discount(kind: 'manual'), positive → order-level Fee(kind: 'surcharge'). Going forward the admin creates them directly: manual discounts target a specific line item or the whole order (distributed proportionally, same largest-remainder rule as promotions); manual fees may be order-level. On paid orders, compensation is preferably a partial Refund — a total decrease after capture creates an outstanding credit — but the manual-discount path remains available via the explicit edit flow.
Discount provenance survives promotion deletion: code/value/value_type snapshots + dependent: :nullify (OSS platform B's SET_NULL-plus-snapshot pattern).
Post-placement admin edits happen through the typed CRUD endpoints (see "Admin management surface"): manual fees and discounts are created/edited directly and trigger recalculation on the changed base; tax lines are never hand-edited — tax follows from the order's state via the provider.
Owner shape confirmed (2026-07-27, with the cart-order-split expansion): the dual cart_id/order_id exactly-one pattern is the unified owner design for LineItem, the typed lines here, and Fulfillment/DeliveryRate — one pattern everywhere, #owner as a method, no polymorphism. Provenance flows through Order#cart_id.
None at this time.
spree/core/app/models/spree/adjustment.rbspree/core/app/models/spree/adjustable/adjustments_updater.rbAdjustmentSource concern: spree/core/app/models/concerns/spree/adjustment_source.rb6.0-cart-order-split.md, 6.0-tax-provider.md, 6.0-fulfillment-and-delivery.md, 6.0-multi-vendor-marketplace.md, 6.0-returns-exchanges-claims.md, 6.0-admin-api.md, decisions.md (2026-03-16 STI namespacing, state→status)packages/modules/{cart,order}/src/models/*-tax-line.ts, *-adjustment.ts (typed tables, nullable tax_rate_id + provider_id + data); OSS platform C surcharge.entity.ts (taxable surcharges), prorate.ts (largest-remainder), issue #4811 (frozen proration weights); the hosted market leader Admin GraphQL Order.taxLines / DiscountApplication / DiscountAllocation / AdditionalFee; OSS platform B saleor/discount/models.py (SET_NULL + snapshots, winner-only via DB constraint)