Skip to content

Buylist Tiered Price Ranges ("Expand Your Range") โ€” Design โ€‹

Date: 2026-07-26 Status: Approved, pending implementation plan Issue: #400

Problem โ€‹

The buylist pays one blended rate across the entire price curve. A merchant configures defaultRate (a single percentage of the basis price), optional per-rarity overrides, one bulk floor rule, and one stopGap ceiling rule (BASE_BUYLIST_DEFAULTS in server/services/buylistConfigService.js). The quote engine resolves them in that order:

bulk (basis < threshold โ†’ flat price)
  โ†’ rarityRates[rarity] ?? defaultRate
  โ†’ stopGap (basis > threshold โ†’ override rate)
  โ†’ ร— conditionMultipliers[condition]

That is a simple range: two step functions bolted onto one flat percentage. Real shops do not buy that way. From the First Pass review, merchants want payouts bracketed by card value, because the economics differ at every band โ€” a $1.50 card is worth a fixed dime regardless of its exact price, while a $150 card is worth a high percentage because the spread is what matters. Expressing that curve today requires picking one rate that is wrong nearly everywhere.

The merchant-supplied target (Magic):

Bulk      = $0.005
$1โ€“1.99   = $0.10
$2โ€“2.99   = $0.50
$3โ€“3.99   = $1
$4โ€“5      = $2
$5โ€“20     = 60%
$20โ€“50    = 65%
$50โ€“100   = 70%
$100+     = 80%

Two properties of that table matter for the design. First, low bands pay flat dollar amounts and high bands pay percentages โ€” the same table mixes both. Second, the table's own bottom row is the bulk rule and its own top row is the stop-gap rule; they are not additional rules layered on top.

Decision โ€‹

Add an optional per-game bracket table of up to 10 rows. Each row declares a lower price bound and pays either a flat dollar amount or a percentage of the basis price. An "Expand Your Range" switch reveals it; the existing simple range remains the default and the only thing a merchant sees until they opt in.

When the table is on it owns the whole price curve: it replaces bulk, defaultRate, and stopGap. Per-rarity overrides continue to apply on top of it, and condition multipliers continue to apply after it.

Singles only. Sealed is explicitly exempt (see Parity).

Config shape โ€‹

Added to BASE_BUYLIST_DEFAULTS, which is game-agnostic and shared by every plugin:

js
tieredPricing: {
    enabled: false,
    brackets: []   // ascending by minPrice; each row has flatAmount XOR rate
}

The merchant example above serializes to:

js
brackets: [
  { minPrice: 0,   flatAmount: 0.005 },
  { minPrice: 1,   flatAmount: 0.10 },
  { minPrice: 2,   flatAmount: 0.50 },
  { minPrice: 3,   flatAmount: 1.00 },
  { minPrice: 4,   flatAmount: 2.00 },
  { minPrice: 5,   rate: 0.60 },
  { minPrice: 20,  rate: 0.65 },
  { minPrice: 50,  rate: 0.70 },
  { minPrice: 100, rate: 0.80 }
]

Rounding note. round2 (server/services/buylistQuoteService.js) rounds per card, and computeOrderTotals sums offerPrice * quantity โ€” it does not sum pre-rounded fractional cents. A flatAmount below $0.01, like the $0.005 bulk row above, therefore pays $0.01 per card, not $0.005. That is architectural and out of scope for this feature to change. The flat-amount input's step is 0.01 (not 0.005) so the UI does not invite a value the system cannot honor; a stored 0.005 still loads and round-trips unchanged for stores that set it before this was documented.

Why lower bounds only, not explicit min + max โ€‹

The merchant writes ranges ($1โ€“1.99, $2โ€“2.99) but those ranges have gaps โ€” a card priced at $1.995 falls between two rows. Storing both bounds means the server must validate for overlap and define a documented fallback for every gap, and the merchant types sixteen numbers instead of eight.

Storing only minPrice, with the upper bound implied by the next row's minPrice, makes gaps and overlaps impossible by construction. The UI still renders the derived range ($1.00 โ€“ $1.99) so it reads the way the merchant wrote it. basisPrice resolves to the last row whose minPrice is less than or equal to it; the final row is open-ended, which is exactly the $100+ semantic.

The first row's minPrice must be 0, so every possible basis price is covered and nothing falls through beneath the table. That row is the bulk band.

Why array replacement is safe โ€‹

deepMerge (server/services/pricingConfigService.js:32) returns the override outright when either side is an array, rather than merging element-wise. So:

  • a PUT carrying brackets replaces the entire list,
  • omitting brackets leaves the stored list untouched,
  • brackets: [] clears it.

This is why brackets need no null-sentinel clearing protocol, unlike rarityRates and sealed.categoryRates (see applyGameBuylistConfigUpdate, which strips explicit nulls because deepMerge never deletes object keys).

Validation โ€‹

Extends buildBuylistConfigUpdateSchema in server/schemas/buylistConfig.js. Server enforces; the client mirrors for UX only (rule 7).

RuleRationale
1โ€“10 bracketsThe issue caps the feature at 10 ranges.
minPrice strictly ascendingBracket resolution assumes order; equal or descending bounds make the winning row ambiguous.
First row minPrice === 0Guarantees total coverage โ€” no basis price can fall beneath the table.
Exactly one of flatAmount / rate per row"Calculated based on which field is filled out" is enforced, not inferred. Both-set or neither-set is rejected rather than silently resolved.
rate reuses the existing rateSchema (0โ€“1)Same fraction-of-basis vocabulary as every other rate in this config.
flatAmount โ‰ฅ 0A payout cannot be negative.
enabled: true in an update requires a non-empty brackets in the same updateThe table can never be switched on empty, which would otherwise silently mean "no rules at all".

The last rule is enforced in the schema rather than post-merge because PUT /api/buylist/config is a partial update with no existing post-merge validation hook, and the client always sends both fields together. The quote engine also falls back to the legacy chain when brackets is empty, as defense in depth against any config that predates or bypasses this rule.

Quote engine โ€‹

applyBuylistRate in server/services/buylistQuoteService.js gains a branch.

basisPrice
   โ”‚
   โ”œโ”€ tieredPricing.enabled && brackets.length ?
   โ”‚       โ”‚
   โ”‚       โ”œโ”€ resolveBracket: last row where minPrice โ‰ค basisPrice
   โ”‚       โ”‚
   โ”‚       โ”œโ”€ rarityRates[rarity] set?  โ†’  basisPrice ร— rarityRate
   โ”‚       โ”‚                                ruleApplied = "rarity:<rarity>"
   โ”‚       โ”œโ”€ bracket.flatAmount set?   โ†’  flatAmount
   โ”‚       โ””โ”€ else                      โ†’  basisPrice ร— bracket.rate
   โ”‚                                        ruleApplied = "bracket:<minPrice>"
   โ”‚
   โ””โ”€ else: existing bulk โ†’ rarity/default โ†’ stopGap chain, unchanged
   โ”‚
   โ””โ”€โ†’ ร— conditionMultipliers[condition] โ†’ round2 โ†’ offerPrice

bulk and stopGap are never consulted on the bracket path. The legacy path is byte-identical to today's behavior when tieredPricing.enabled is false, which is every existing store.

Rarity precedence. A per-rarity override wins over the bracket's payout, including over a flat-dollar row โ€” a rarity rate is a percentage of the basis price, so it is only meaningful as a replacement for the whole payout, not as a modifier of a flat amount. This matches the issue's "rarity/card overrides continue to apply below the brackets."

Condition multipliers apply to flat-dollar rows. The issue specifies that brackets resolve "before condition multipliers," so a damaged card in the $0 row pays $0.005 ร— 0.25. This differs from today's bulk rule, which returns its flat price without applying the multiplier โ€” but bulk is bypassed whenever the table is on, so no single quote can exhibit both behaviors.

No in-place mutation (ยง5.11). resolveBracket iterates a copy ([...brackets]) rather than sorting the config array it was handed. The schema already guarantees ascending order; the copy exists so a defensive sort can never mutate store.buylistConfig through the resolved config object.

ruleApplied. New values take the form bracket:<minPrice> (e.g. bracket:5). The field is a free-form String on BuylistOrder.lines (server/models/BuylistOrder.js:38) with no enum and no client-side consumer, so new values need no migration. minPrice rather than a row index keeps the value meaningful after a merchant reorders or inserts rows.

Client โ€‹

client/src/pages/buylist/BuylistSettingsTab.jsx gains an "Expand Your Range" card, placed directly above the existing Overrides card:

  • A Switch bound to tieredPricing.enabled.
  • When on, up to 10 rows of From $__ โ†’ pay $__ or __%, with the upper bound rendered as static text derived from the next row's minPrice ($1.00 โ€“ $1.99). The final row renders open-ended ($100.00+). Row 1 is pinned at $0: it reads Under $1.00 when a row follows it, and All cards when it is the only row.
  • Add / remove row controls, capped at 10 to mirror the server rule, with the cap commented as mirroring buylistConfig.js (rule 7).
  • Filling the flat-dollar field clears the percentage field for that row and vice versa, so the XOR the server enforces cannot be violated from the UI. A freshly added row has both fields blank, which is not yet savable โ€” Save is disabled with an inline message naming the incomplete row, rather than letting the merchant discover it as a 400.
  • The Overrides card (bulk, stop-gap) renders visibly disabled while the table is on, with one line explaining that the table replaced those rules. This is the merchant-visible signal that their bulk configuration is not silently being ignored.

Rates stay percentages in the UI and fractions over the wire, via the existing toPct / toFraction helpers. Flat dollar amounts are dollars on both sides.

Parity (ยง5.1) โ€‹

PluginStatus
mtgMirrored โ€” inherits tieredPricing from BASE_BUYLIST_DEFAULTS.
pokemonMirrored โ€” same.
riftboundMirrored โ€” same.

Brackets are pure price arithmetic on basisPrice. They introduce no per-game vocabulary: no rarity ids, finishes, set codes, or metafield keys (ยง5.4), so there is nothing to diverge between plugins. The one game-specific interaction โ€” the rarity override that beats a bracket โ€” reads rarityRates, which buildBuylistConfigUpdateSchema already validates against plugin.rarities per game.

Sealed is exempt. Sealed keeps its own sealed.defaultRate + sealed.categoryRates model and applySealedBuylistRate (docs/superpowers/specs/2026-07-23-sealed-buylist-support-design.md, which anticipated this: "If tiered ranges later ship, applying them to sealed is a separate, additive change"). Sealed prices cluster in a narrow high band where a nine-row curve earns little, and a $0.005 bulk row is meaningless for a booster box.

Surfaces that need no change โ€‹

  • Public customer portal. publicBuylistService.quotePublicLines reaches applyBuylistRate through priceSelectedLines, so bracket pricing applies to customer-submitted quotes automatically. getPublicStoreSummary exposes only enabled / sealedEnabled / branding per game โ€” no rates โ€” so no bracket data leaks to the public surface.
  • BuylistOrder model. ruleApplied is an unconstrained String; no schema change, therefore no ยง5.2 round-trip concern.
  • Order totals. computeOrderTotals consumes offerPrice, never the rule that produced it.

Testing โ€‹

server/schemas/buylistConfig.test.js

  • Accepts the merchant's full 9-row example.
  • Rejects: 11 rows; descending or duplicate minPrice; a first row with minPrice > 0; a row with both flatAmount and rate; a row with neither; a negative flatAmount; a rate above 1; enabled: true with no brackets.
  • Accepts brackets: [] (the clear case) and enabled: false alone.

server/services/buylistQuoteService.test.js

  • Boundary resolution: a basis exactly equal to a minPrice lands in that row, not the one below; a basis between rows lands in the lower row; a basis above the top row lands in the top row; a basis of 0 lands in row 1.
  • Flat-dollar row pays the flat amount; percentage row pays basis ร— rate.
  • A rarity override beats both a flat row and a percentage row.
  • bulk and stopGap are ignored when the table is on, proven by configuring both with values that would visibly change the answer.
  • Condition multiplier applies on the bracket path, including to a flat row.
  • With enabled: false, every existing legacy-chain assertion still holds.
  • With enabled: true but brackets: [], the legacy chain runs.
  • resolveBracket does not mutate the brackets array it is given (ยง5.11).

server/services/buylistConfigService.test.js

  • tieredPricing defaults are present for all three games.
  • A PUT with a new brackets array replaces rather than merges the stored one (write 9 rows, then write 2, read back 2).

client/src/pages/buylist/BuylistSettingsTab.test.jsx

  • The table is hidden until the switch is on.
  • Adding rows stops at 10; removing works.
  • Entering a flat amount clears that row's percentage and vice versa.
  • A row with neither field filled disables Save and shows the inline message.
  • The Overrides card is disabled while the table is on.
  • Save sends tieredPricing: { enabled, brackets } with fractions for rate and dollars for flatAmount.

Out of scope โ€‹

  • Applying brackets to sealed products.
  • Per-card (rather than per-rarity) overrides โ€” rarityRates is the only override axis this design touches.
  • Importing or exporting a bracket table between games or stores; each game is configured on its own tab, as every other buylist setting already is.
  • Any change to how basisPrice itself is resolved (priceBasis, market/lowest/lower-of).