Back to Spree

Split Adjustments into Typed Tables

docs/plans/6.0-split-adjustments.md

5.6.138.6 KB
Original Source

Split Adjustments into Typed Tables

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

Summary

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.

Problem

1. One table does too many jobs

The same spree_adjustments row represents:

  • Tax (from TaxRate — included in price or additional)
  • Promotions (from PromotionAction, competes with other promos)
  • Shipping discounts (from FreeShipping PromotionAction — deliberately persisted even at zero amount)
  • Manual admin adjustments (source-less, order-level, may be positive surcharges)
  • Return credits (legacy ReturnAuthorization rows — no longer written)
  • Unknown custom source types from extensions (special-cased by the admin order summary)

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.

2. Polymorphic FKs prevent structural guarantees

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.

3. Eligible-flag bookkeeping

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.

4. The state machine is a misplaced, leaky freeze

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

5. Spree is the industry outlier

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.

Current blast radius (verified 2026-07-27)

WhatWhere
spree_adjustments table13 columns
Spree::Adjustment model131 lines, 20 scopes, state machine
Adjustable::AdjustmentsUpdaterTwo-pass orchestration (non-tax → persist taxable totals → tax)
AdjustersAdjuster::Tax, Adjuster::Promotion registered; Adjuster::Base abstract. (PromotionAccumulator is dead code — zero callers; delete freely.)
AdjustmentSource concernTaxRate + 3 PromotionActions, incl. deleted-source handling
Order, LineItem, Shipmenthas_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 v3Read-only /admin/orders/:id/adjustments endpoints, Admin::AdjustmentSerializer, ?expand=adjustments on order/line_item/fulfillment serializers
SDK / SPAadmin-sdk orders.adjustments resource, generated TS/Zod types, dashboard order page
Legacy adminFull 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

Industry Alignment

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

ConcernSpree 6.0The hosted market leaderOSS platform AOSS platform BOSS platform C
TaxTyped rows: amount + rate + nullable tax_rate_id + provider_idEmbedded snapshots (title, rate), no FKTyped tables; rate + nullable tax_rate_id + provider_id + dataNo tax rows; net/gross columnsEmbedded JSON (rate only)
DiscountsTyped rows, promo FKs + code snapshotApplication (intent) + per-line allocationTyped adjustment rows, code + promotion_idTyped rows, SET_NULL FKs + snapshotsEmbedded JSON, string source
FeesFirst-class taxable Fee, line/fulfillment/order scopedNone merchant-facing (Duty/AdditionalFee are platform-only, taxed)None (adjustments are CHECK >= 0 discounts)NoneSurcharge table, taxed, order-level only
Order-level discountsProrated to linesProrated to lines (+ intent record)Prorated to linesOne order row + prorated totalsProrated to lines (largest-remainder)
Losers persistedNoNoNo (stacks all valid)No (DB constraint)No (replace on recompute)
Per-row stateNoneNoneNoneNoneNone

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.

Key Decisions (do not deviate without discussion)

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

Design Details

TaxLine

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

Discount

ruby
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 amountOrder#has_free_shipping? tests row existence; the zero-skip pattern used elsewhere does not apply to this action.

Fee (extensible, taxable)

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

Associations

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

Recalculation

Adjustable::AdjustmentsUpdater and Adjustable::Adjuster::* are replaced. OrderUpdater (and its cart counterpart) runs:

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

Promotion application (winner-only in 6.0, stacking-ready for 6.1)

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:

ruby
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; CreateItemAdjustmentsCreateItemDiscounts as part of that rename wave.

Extensibility

ruby
# 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.

Manual discounts and fees (back-office)

The legacy "manual adjustment with a label" splits by sign, keeping both directions of today's capability:

  • Compensation / goodwill (scratched package, late delivery, price-match): manual 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.
  • Surcharge (customs, handling, phone-order fee): manual 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.

Admin management surface (API / SDK / Dashboard)

Typed rows are managed through the Admin API v3, not just read:

ResourceSurfaceWrites
/admin/orders/:order_id/tax_linesindex, getRead-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/discountsfull CRUDManual 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/feesfull CRUDManual 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.

  • admin-sdk: 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.
  • React Dashboard: the order detail page replaces the legacy adjustments list with typed panels — tax summary grouped by rate/label, fees list, discounts list — plus "Add fee" and "Add discount" actions (amount or percent via 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.

Migration Path

Sequencing (cross-plan)

  1. 6.0-cart-order-split.md — owner semantics + Spree::Carts::Complete (gating dependency)
  2. 6.0-fulfillment-and-delivery.md Phase 1 + 6.0-normalize-state-to-status.md — vocabulary, so the new tables are born correct
  3. This plan (Phase 0 freeze → tables → data migration → model migration → API)
  4. 6.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)
  5. 6.0-returns-exchanges-claims.md, then 6.0-multi-vendor-marketplace.md order splitting (consumes typed lines)

Phase 0: Preconditions

  • Order-level recalculation freeze in 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.
  • Cart/order owner semantics finalized (see Depends on).

Phase 1: Create new tables

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.

Phase 2: Data migration (rake task, never in-migration)

For each spree_adjustments row:

SourceDestination
Spree::TaxRateTaxLine (copy rate from the TaxRate; provider_id: 'internal')
Spree::PromotionAction, eligibleDiscount (kind 'promotion', snapshot code/value from the promotion)
Spree::PromotionAction, eligible: falseskip (losers are not migrated)
nil source, amount < 0Discount (kind 'manual')
nil source, amount ≥ 0Fee (kind 'surcharge', order-level)
Spree::ReturnAuthorizationskip — legacy rows; their orders are marked recalculation-frozen (totals no longer reproducible from typed tables)
unknown custom sourceFee (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:

  • Additive schema first. The new tables + 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.
  • Completed orders are never recalculated. The task re-expresses detail rows as typed rows; order-level denormalized totals and the grand total are left byte-for-byte untouched, so no customer-visible amount changes during upgrade. Combined with the order-level freeze (Resolved Question 7), historical totals stay stable afterwards too.
  • Per-order reconciliation. After converting an order, the task asserts the typed sums match the legacy totals (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.
  • Line-level caveat: distributing order-level discounts updates per-line totals on historical orders (order totals unchanged), which shifts per-line exports and future partial-refund weighting for past data. The task logs affected orders.
  • In-flight carts/incomplete orders don't need faithful conversion — the new engine recalculates them from scratch on first touch.
  • Rollback window: 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.

Phase 3: Model migration

  • New models + Spree::Adjusters::Base; remove Adjustment, AdjustmentSource, Adjustable::AdjustmentsUpdater, Adjustable::Adjuster::*, PromotionAccumulator (dead), CalculatedAdjustments usage for adjustments
  • OrderUpdater typed recalculation with freeze guard + two-pass ordering; cart counterpart
  • Promotion side: PromotionActions::* write Discounts (winner-only, clamped); usage-limit accounting, coupon handlers, CouponCodesHandler, OrderPromotion#amount, FreeShipping zero-amount semantics, CreateItemAdjustments negative-total guard
  • Tax side: Internal provider writes TaxLines (replace-all); TaxRate keeps no write path
  • Order/LineItem/Fulfillment readers rewritten (order_level_promo_total, shipping_discount, valid_promotion_ids, cart_promo_total, has_free_shipping?, fully_discounted?, taxable_amount, …); OrderMerger, Cart::Empty, refund calculators
  • CanCanCan permission sets: grants on TaxLine/Discount/Fee replace Spree::Adjustment
  • Factories + specs (~65 files), docs/developer/core-concepts/adjustments.mdx, custom-promotion.mdx

Phase 4: API + serializer + SDK + admin surfaces

  • Admin API v3: /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)
  • Full type pipeline: typelizer → Zod → integration specs → OpenAPI; admin-sdk orders.adjustmentsorders.{taxLines,discounts,fees} resources; dashboard order page typed panels + add-fee/add-discount forms. Admin SDK is unreleased — break cleanly, no bridge
  • Legacy Rails admin ships no replacement — spree/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
  • Emails: _purchased_items_table partials iterate typed associations
  • Event payloads follow the serializer change automatically (Publishable resolves V3 serializers)

Phase 5: Cleanup (6.1)

  • Drop spree_adjustments, remove legacy scopes/aliases and the migration rake task

Constraints on Current Work

  • Don't add new adjustment source types or rely on source_type string matching. New fee/discount types should be designed for the typed model.
  • Don't add state machine transitions to Adjustment. It's being removed.
  • Don't build new features on TaxRate.adjust or AdjustmentSource. The tax write path moves to the provider seam.
  • New code touching line items uses line_item.owner, not line_item.order (cart/order split constraint).
  • No new shipment references — fulfillment vocabulary (fulfillment plan constraint).
  • Keep denormalized totals on LineItem/Shipment/Order — all of them, including taxable_adjustment_total / non_taxable_adjustment_total and pre_tax_amount; they're the performance optimization.

Resolved Questions

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

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

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

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

  5. Order-level fees are legal (both adjustable FKs nil). At vendor split they prorate across child orders by item totals.

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

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

  8. Tax write path is provider-owned (6.0-tax-provider.md). Replace-all set semantics per estimate; Spree.adjusters covers discounts/fees only.

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

  10. Discount provenance survives promotion deletion: code/value/value_type snapshots + dependent: :nullify (OSS platform B's SET_NULL-plus-snapshot pattern).

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

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

Open Questions

None at this time.

References

  • Current adjustment model: spree/core/app/models/spree/adjustment.rb
  • Current recalculation: spree/core/app/models/spree/adjustable/adjustments_updater.rb
  • AdjustmentSource concern: spree/core/app/models/concerns/spree/adjustment_source.rb
  • Related plans: 6.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)
  • Competitor review (2026-07-27): OSS platform A 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)