Skip to content

Independent Cash and Store-Credit Buylist Rates โ€” Design โ€‹

Date: 2026-07-28 Status: Approved 2026-07-28; implemented (PRs 1โ€“4 landed) Source: Buylist Feature Feedback โ€” "In this version of the buylist tool there is a master / slave relationship between cash and store credit. This should be separated. Not all stores offer cash and the rates are not always as clean as 15% less. There should be setting for cash and credit which are independant of each other."

Problem โ€‹

The buylist computes exactly one payout per line and derives the other from it. applyBuylistRate returns a single offerPrice (server/services/buylistQuoteService.js), and computeOrderTotals reads it as the cash figure:

js
cashTotal   = ฮฃ offerPrice ร— quantity
creditTotal = ฮฃ offerPrice ร— quantity ร— (1 + storeCreditBonus)

So the entire rate stack โ€” defaultRate, rarityRates, bulk, stopGap, tieredPricing, and the sealed defaultRate/categoryRates โ€” configures cash, and store credit is one multiplier bolted on the end. Three consequences:

  1. A store that doesn't pay cash still configures cash. There is no way to turn a payout method off. Every order carries both totals, the portal shows both, and the merchant's real credit rates have to be back-solved from a cash table they never pay against.
  2. One knob can't express two curves. A shop paying 60% in credit and 40% in cash on high-value singles, but the same flat dime on bulk either way, has no representation. storeCreditBonus is a single fraction applied uniformly.
  3. The knob's direction doesn't match how merchants talk. The feedback says cash is "15% less"; the config stores storeCreditBonus, credit as a markup on cash. alchemists-refuge currently has defaultRate: 0.20 and storeCreditBonus: 0.15, which pays credit at 23% of market โ€” not cash at 15% below credit. Whichever was intended, the model makes the other one inexpressible, and the merchant can't see which they configured.

A fourth, smaller consequence falls out of the same design: because credit is never persisted per line, BuylistReviewPage has to re-derive it from payout.creditBonus, which is a blended rate on a mixed-game order โ€” so the per-line Store Credit Offer column shows โ€” for every cross-game order today.

Decision โ€‹

Replace the single rate stack with two symmetric payout tracks, cash and storeCredit. Each track can be enabled or disabled, and each either owns a full rate stack or is linked to the other track by a bonus.

Linked mode is what preserves today's behaviour and keeps the common case a one-knob setting: a shop that really does pay 15% less in cash sets one number and never opens a second rate table. A shop with genuinely different curves switches the track to rates and gets its own defaultRate, rarity overrides, bulk/stop-gap rules, price-range table and sealed rates.

Each line stores both payouts. That is what makes the two tracks genuinely independent rather than one derived at render time, and it retires payout.creditBonus along with the mixed-game โ€” above.

Shape โ€‹

Per game, under Store.buylistConfig[game]:

js
{
  // Unchanged, and deliberately NOT per track:
  enabled, priceBasis, conditionMultipliers, instantQuote,
  sealed: { enabled, useMsrpFallback },

  payouts: {
    cash: {
      enabled: true,
      mode: 'rates',                 // 'rates' | 'linked'
      linkBonus: null,               // used only when mode === 'linked'
      rates: RATE_STACK
    },
    storeCredit: {
      enabled: true,
      mode: 'linked',
      linkBonus: 0.25,               // storeCredit = cash ร— (1 + 0.25)
      rates: RATE_STACK              // retained, inert while linked
    }
  }
}

where

js
RATE_STACK = {
  defaultRate, rarityRates, bulk, stopGap, tieredPricing,
  sealed: { defaultRate, categoryRates }
}

What stays shared, and why. conditionMultipliers describes the card, not the payout โ€” a lightly-played copy is worth 85% of a near-mint one whichever way you pay for it. priceBasis selects the market source. sealed.enabled and sealed.useMsrpFallback decide whether sealed is quoted at all and where its basis comes from. None of these are payout-dependent, and duplicating them would create two answers to one question. (See Open questions โ€” the condition curve is the one I'd genuinely reconsider.)

prioritization (outOfStockBoost, hotList) is not carried into the tracks: it is declared in BASE_BUYLIST_DEFAULTS and in the Zod schema and read by nothing โ€” dead scaffold in the sense of CLAUDE.md ยง5.9. It should be deleted or wired in its own change, not doubled here.

Rules โ€‹

Enforced in the Zod schema (server/schemas/buylistConfig.js), so the server is the authority and the settings UI only mirrors it (rule 7):

  • At least one track must be enabled. Disabling both is a 400, not a store that silently quotes nothing.
  • At most one track may be mode: 'linked', and its counterpart must be both enabled and mode: 'rates'. Two linked tracks have no base to resolve from; linking to a disabled track means paying against rates nobody configured.
  • linkBonus is required when mode === 'linked' and must be >= -1 (โˆ’1 pays nothing, positive is a premium). Note this is a wider range than the existing rateSchema (0..1) โ€” a cash track linked at "15% less" is -0.15, which today's schema would reject.
  • A disabled track is not selectable as payout.method on create or PATCH.

Rate resolution โ€‹

applyBuylistRate already takes (lineFacts, buylistConfig). It becomes applyBuylistRate(lineFacts, rateStack, { conditionMultipliers }) โ€” same algorithm, called once per track, with the shared multipliers passed alongside. No new branching, no if (track === ...).

A new priceLineForAllTracks(lineFacts, gameConfig) orchestrates:

  1. For each enabled track with mode: 'rates', run the stack.
  2. For the linked track, take its base track's offerPrice ร— (1 + linkBonus), rounded with the same round2.
  3. Return { cash: {price, ruleApplied}, storeCredit: {price, ruleApplied} }, with a disabled track omitted entirely.

A linked track's ruleApplied records the derivation (linked:cash:+0.25) so the review screen can explain a number the merchant didn't type.

Line and order persistence โ€‹

buylistLineSchema gains a declared sub-document (CLAUDE.md ยง5.2 โ€” every field listed individually, with a round-trip test that fails if a line is deleted):

js
offers: {
  cash:        { price: Number, ruleApplied: String },
  storeCredit: { price: Number, ruleApplied: String }
}

offerPrice is not kept as a second writer. Orders written before this change still have it, so reads go through one helper โ€” readLineOffers(line, payout) โ€” which returns the stored offers when present and otherwise reconstructs {cash: offerPrice, storeCredit: offerPrice ร— (1 + payout.creditBonus)} from the legacy pair. One reader, no divergence (ยง5.9).

payout keeps cashTotal, creditTotal, marketTotal and method. creditBonus stops being read once the review screen takes its per-line credit from offers; it is written for one release, then dropped.

Migration โ€‹

Small and verifiable: production currently holds 3 game-configs across 2 stores and 7 buylist orders (queried 2026-07-28).

Per game-config, one scripted pass with a --dry-run (matching npm run migrate:pricing-config):

payouts.cash        = { enabled: true, mode: 'rates',  linkBonus: null,               rates: <the 7 existing top-level fields> }
payouts.storeCredit = { enabled: true, mode: 'linked', linkBonus: <storeCreditBonus>, rates: <copy of the same fields> }
delete the 7 top-level rate fields and storeCreditBonus

The copy into storeCredit.rates is a starting point, so a merchant switching that track to rates edits real numbers rather than plugin defaults.

Migration invariant, asserted as a test, not a claim: for every stored config, quoting a fixed basket of lines through the old code and the new code produces identical cashTotal and creditTotal. That is the whole safety argument โ€” the migration is only correct if no merchant's numbers move.

Existing orders are snapshots and are left alone; readLineOffers covers them. A pending order edited after the migration re-prices under the new config, which is already true of any config change.

Surfaces โ€‹

SurfaceChange
BuylistSettingsTabTwo payout cards, each with an enable switch and a rates/linked toggle. Linked shows one bonus field; rates reveals that track's full stack (default rate, rarity table, bulk, stop-gap, price ranges, sealed rates).
BuylistReviewPageStore Credit Offer becomes a real editable column reading offers.storeCredit; per-track manual override. A disabled track's column disappears. The mixed-game โ€” goes away.
BuylistOrdersTabHide the column for a disabled track.
Public portalQuote and status pages show only enabled methods. Customers don't choose a method today (createPublicOrder hardcodes store_credit) โ€” if the store has credit disabled, that hardcode becomes a bug and must read the store's enabled tracks.

Parity (ยง5.1) โ€‹

All of this is game-agnostic price math in shared services and schemas. rarityRates keys still come from plugin.rarities and categoryRates from SEALED_CATEGORIES, so duplicating the stack per track reuses the existing per-game schema builder unchanged โ€” mtg, pokemon and riftbound inherit it identically, none exempt. The only per-game asymmetry is the one that already exists: categoryRates is MTG-only because only MTG has a sealed taxonomy.

PR chain โ€‹

Five small PRs, in the repo's usual style โ€” each one shippable, none leaving a half-wired field:

  1. Config model, migration and quote engine. payouts shape, Zod rules, normalize-on-read, the migration script with --dry-run, the invariant test above; applyBuylistRate takes a rate stack, priceLineForAllTracks prices every enabled track, lines persist offers, totals come from stored per-track prices, readLineOffers covers legacy orders.

    Merged from the original PRs 1 and 2 during implementation. Splitting them would have shipped a payouts config that nothing consumed for one release, which is the ยง5.9 dead-scaffold shape this repo keeps getting bitten by. The behaviour-neutrality argument survives the merge intact: the invariant test asserts identical totals for every stored config, which is a stronger check than "this PR doesn't touch the engine".

  2. Settings UI. The two payout cards. This is the PR that first lets a merchant express a second curve.

  3. Review screen + orders list. Editable per-track offers, per-track manual override, columns follow enabled tracks.

    The PATCH contract's single offerPrice is gone, replaced by offers: { cash?, storeCredit? } โ€” omitting a method means "re-price it from the rules", so a shop can hand-set store credit while cash stays on the rules. A linked method still follows its base when only the base was typed; an explicit figure for the linked method wins over the link, since typing a number is the more specific instruction. Two decisions worth recording: editing anything an offer derives from (quantity, condition, market) releases BOTH overrides rather than freezing a stale figure, and clearing an offer field back to blank releases that method rather than meaning "pay $0".

  4. Portal. Only offer enabled methods; fix the hardcoded store_credit.

    Landed ahead of PR 3, because a store could disable a method as soon as PR 2 shipped and the hardcoded store_credit was a live bug from that moment. Also settled a question this design left implicit: a payout total is null, not 0, for a method the store doesn't offer. "We pay $0.00 cash" reads as a valuation of the customer's cards; the absence of a figure is the honest representation, and every render site drops the row rather than printing a placeholder. A total is only computed when every included line carries that track, so a mixed-game order where one game is credit-only doesn't quote a partial cash total the shop never agreed to. An order with nothing included still totals 0 in both โ€” "nothing to pay for" is a different statement from "we don't pay this way".

PR 1 is the one worth reviewing hardest โ€” everything after it is mechanical if the shape is right.

Open questions โ€‹

  1. Should conditionMultipliers be per track? Answered: no (2026-07-28). It stays shared. Condition is a property of the card, not the payout.
  2. Which track should be the default base? The migration makes cash the base (it's what today's rates mean, so no numbers move). For a store that disables cash, the schema then forces storeCredit to mode: 'rates'. That's correct but means "turn off cash" is a two-step change in the UI. Worth the UI smoothing it over automatically?
  3. Should the UI warn when cash exceeds credit? I'd not enforce any relationship โ€” merchants know their business โ€” but an unintended inversion is an expensive mistake. Soft warning, or nothing?
  4. Does payout.method still mean anything downstream? Answered: not currently (2026-07-28). It is stored and displayed and nothing else reads it, so the enabled-track check is a correctness guard on what a merchant or customer can select, not a safeguard on money movement. If store-credit issuance is ever wired to it, revisit the strictness then.
  5. prioritization โ€” delete or wire? It has been unread config since it was added. This design deliberately doesn't propagate it; it should go one way or the other in its own PR.

Risks โ€‹

  • Silent rate changes. The one genuinely dangerous failure is a migration that shifts what a merchant pays. Mitigated by the old-vs-new invariant test over every stored config, and by PR 1 being behaviour-neutral by construction.
  • Two rate tables is a lot of settings UI. Linked mode is the mitigation โ€” a merchant who doesn't need two curves never sees the second table.
  • linkBonus widens the accepted rate range below zero. rateSchema is reused widely; the negative range must be a separate schema, not a relaxation of the shared one, or every other rate silently accepts negatives.