Skip to content

Buylist Tiered Price Ranges Implementation Plan ​

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let a merchant configure up to 10 price brackets on the buylist, each paying a flat dollar amount or a percentage of the card's basis price, behind an "Expand Your Range" switch.

Architecture: A new tieredPricing: { enabled, brackets } block in the shared per-game buylist defaults. Brackets store lower bounds only (minPrice), so the upper bound of a row is the next row's lower bound and gaps/overlaps are impossible. applyBuylistRate gains one branch: when the table is on and non-empty it replaces the bulk β†’ defaultRate β†’ stopGap chain entirely, with per-rarity overrides still winning and condition multipliers still applying afterwards. The client mirrors the server's validation rules for UX only.

Tech Stack: Node.js/CommonJS server, Zod 4.3.6 validation, Mongoose (Store.buylistConfig is a Mixed field keyed by game id), Vitest tests (ESM import syntax even though source is CommonJS), React 18 + retroui components on the client.

Design doc: docs/superpowers/specs/2026-07-26-buylist-tiered-price-ranges-design.mdIssue: #400

Global Constraints ​

  • Dependencies must be installed before any test runs. This worktree starts with no node_modules. Run npm run install:all once, at the start. Both npm test and npm run test:client fail without it.
  • Rates are fractions over the wire, percentages in the UI. rate: 0.60 on the server is 60 in a client input. flatAmount is dollars on both sides. The client's existing toPct / toFraction helpers do the conversion.
  • game is never defaulted (Β§5.5). No task in this plan adds a || 'mtg'-style fallback. Brackets are game-agnostic; the per-game routing already exists.
  • Server enforces, client decorates (rule 7). Every client-side bracket check must carry a comment naming server/schemas/buylistConfig.js as the real enforcement point.
  • Never mutate an input array in place (Β§5.11). Bracket resolution sorts a copy; client row edits build new arrays and new row objects.
  • Sub-cent per-card payouts round up to $0.01. round2 is applied to the per-card offerPrice, so the merchant's $0.005 bulk row pays $0.01/card. This matches the existing bulk rule's behavior exactly and is pinned by a test in Task 3 β€” it is not a regression this feature introduces.
  • Test files use ESM import; source files use CommonJS require. Do not "fix" this mismatch.
  • Server tests run from the repo root: npx vitest run <path>. Client tests: npx vitest run --config vitest.client.config.js <path>.
  • Commit message style: one imperative sentence stating the merchant-visible outcome, sentence case, no trailing period. Every commit ends with the Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> trailer.
  • Husky pre-commit runs ESLint. Never pass --no-verify.

File Structure ​

FileChangeResponsibility
server/services/buylistConfigService.jsModifyDeclares the tieredPricing default in BASE_BUYLIST_DEFAULTS, inherited identically by mtg/pokemon/riftbound.
server/services/buylistConfigService.test.jsModifyProves the default exists for every game and that deepMerge replaces bracket arrays wholesale.
server/schemas/buylistConfig.jsModifyOwns every bracket-table rule: count, ordering, coverage, flat-XOR-rate, enable-requires-rows.
server/schemas/buylistConfig.test.jsModifyOne test per rejection rule, plus the merchant's real 9-row table as the acceptance case.
server/services/buylistQuoteService.jsModifyresolveBracket helper + the bracket branch in applyBuylistRate.
server/services/buylistQuoteService.test.jsModifyBoundary resolution, flat vs rate, rarity precedence, bulk/stop-gap bypass, legacy fallbacks.
client/src/pages/buylist/BuylistSettingsTab.jsxModifyThe "Expand Your Range" card, row editing, derived range labels, mirrored validation, disabling the Overrides card.
client/src/pages/buylist/BuylistSettingsTab.test.jsxModifyToggle, add/remove, XOR field clearing, Overrides disabled, save payload shape.

No new files. No route change β€” PUT /api/buylist/config already validates via buildBuylistConfigUpdateSchema(getPlugin(game)) and persists via applyGameBuylistConfigUpdate (server/routes/api.js:2152-2181), both of which pick up the new field automatically.


Task 0: Install dependencies ​

Files: none (environment setup)

  • [ ] Step 1: Install root and client dependencies
bash
npm run install:all

Expected: exits 0. node_modules/ and client/node_modules/ both exist afterwards. This takes several minutes on a cold worktree.

  • [ ] Step 2: Confirm the existing suite is green before changing anything
bash
npx vitest run server/services/buylistQuoteService.test.js server/services/buylistConfigService.test.js server/schemas/buylistConfig.test.js

Expected: PASS, 3 files. If anything fails here, stop β€” it is a pre-existing failure, not something this plan caused.

No commit for this task.


Task 1: Config default for the bracket table ​

Files:

  • Modify: server/services/buylistConfigService.js:19-42 (the BASE_BUYLIST_DEFAULTS object)
  • Test: server/services/buylistConfigService.test.js

Interfaces:

  • Consumes: nothing from earlier tasks.

  • Produces: getDefaultGameBuylistConfig(gameId) and getGameBuylistConfig(store, gameId) now return an object containing tieredPricing: { enabled: boolean, brackets: Array<{ minPrice: number, flatAmount?: number, rate?: number }> }. Tasks 2, 3 and 4 all read this shape.

  • [ ] Step 1: Write the failing tests

Append to server/services/buylistConfigService.test.js:

javascript
describe('tiered price ranges (issue #400)', () => {
    it('defaults to a disabled, empty bracket table for every registered game', () => {
        for (const game of ['mtg', 'pokemon', 'riftbound']) {
            const config = getDefaultGameBuylistConfig(game);
            expect(config.tieredPricing).toEqual({ enabled: false, brackets: [] });
        }
    });

    it('replaces a stored brackets array wholesale instead of merging it element-wise', () => {
        const store = fakeStore();
        applyGameBuylistConfigUpdate(store, 'mtg', {
            tieredPricing: {
                enabled: true,
                brackets: [
                    { minPrice: 0, flatAmount: 0.005 },
                    { minPrice: 1, flatAmount: 0.10 },
                    { minPrice: 5, rate: 0.60 }
                ]
            }
        });

        const merged = applyGameBuylistConfigUpdate(store, 'mtg', {
            tieredPricing: { enabled: true, brackets: [{ minPrice: 0, rate: 0.30 }] }
        });

        expect(merged.tieredPricing.brackets).toEqual([{ minPrice: 0, rate: 0.30 }]);
    });

    it('leaves a stored brackets array untouched when an update omits it', () => {
        const store = fakeStore();
        applyGameBuylistConfigUpdate(store, 'mtg', {
            tieredPricing: { enabled: true, brackets: [{ minPrice: 0, rate: 0.30 }] }
        });

        const merged = applyGameBuylistConfigUpdate(store, 'mtg', { defaultRate: 0.45 });

        expect(merged.tieredPricing.enabled).toBe(true);
        expect(merged.tieredPricing.brackets).toEqual([{ minPrice: 0, rate: 0.30 }]);
        expect(merged.defaultRate).toBe(0.45);
    });

    it('clears a stored brackets array when an update sends an empty one', () => {
        const store = fakeStore();
        applyGameBuylistConfigUpdate(store, 'mtg', {
            tieredPricing: { enabled: true, brackets: [{ minPrice: 0, rate: 0.30 }] }
        });

        const merged = applyGameBuylistConfigUpdate(store, 'mtg', {
            tieredPricing: { enabled: false, brackets: [] }
        });

        expect(merged.tieredPricing).toEqual({ enabled: false, brackets: [] });
    });

    it('keeps one game\'s bracket table out of another game\'s config', () => {
        const store = fakeStore();
        applyGameBuylistConfigUpdate(store, 'mtg', {
            tieredPricing: { enabled: true, brackets: [{ minPrice: 0, rate: 0.30 }] }
        });

        expect(getGameBuylistConfig(store, 'pokemon').tieredPricing)
            .toEqual({ enabled: false, brackets: [] });
    });
});
  • [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/services/buylistConfigService.test.js

Expected: FAIL β€” the first test reports expected undefined to deeply equal { enabled: false, brackets: [] }.

  • [ ] Step 3: Add the default

In server/services/buylistConfigService.js, insert directly after the stopGap line inside BASE_BUYLIST_DEFAULTS:

javascript
    stopGap: { enabled: true, threshold: 5.00, rate: 0.50 },
    // Optional bracketed payout curve -- the "Expand Your Range" table from
    // issue #400. When enabled with a non-empty brackets list it REPLACES
    // bulk/defaultRate/stopGap in applyBuylistRate; rarity overrides and
    // condition multipliers still apply. Rows store a lower bound only, so the
    // upper bound of a row is the next row's minPrice and gaps are impossible;
    // each row pays flatAmount XOR rate. Pure price math with no per-game
    // vocabulary, so mtg/pokemon/riftbound all inherit this identically (Β§5.1).
    tieredPricing: { enabled: false, brackets: [] },
    conditionMultipliers: { nm: 1.0, lp: 0.85, mp: 0.70, hp: 0.50, damaged: 0.25 },

(Only the tieredPricing line is new; the surrounding lines are shown so the insertion point is unambiguous.)

  • [ ] Step 4: Run the tests to verify they pass
bash
npx vitest run server/services/buylistConfigService.test.js

Expected: PASS, all tests in the file including the 5 new ones.

  • [ ] Step 5: Commit
bash
git add server/services/buylistConfigService.js server/services/buylistConfigService.test.js
git commit -m "$(cat <<'EOF'
Add a per-game bracket table to buylist config defaults

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"

Task 2: Bracket-table validation ​

Files:

  • Modify: server/schemas/buylistConfig.js
  • Test: server/schemas/buylistConfig.test.js

Interfaces:

  • Consumes: the tieredPricing shape from Task 1.
  • Produces: buildBuylistConfigUpdateSchema(plugin) now accepts an optional tieredPricing: { enabled?: boolean, brackets?: Array<{ minPrice: number, flatAmount?: number, rate?: number }> } and rejects every malformed table listed below. PUT /api/buylist/config picks this up with no route change.

The zod idioms below (ctx.addIssue({ code: 'custom' }) inside superRefine, .refine() on a .strict() object, .optional() on the resulting effects wrapper) were verified against this repo's installed zod 4.3.6 β€” do not substitute z.ZodIssueCode.custom.

  • [ ] Step 1: Write the failing tests

Append to server/schemas/buylistConfig.test.js:

javascript
// The real merchant table from issue #400: low bands pay flat dollars, high
// bands pay percentages, bottom row is bulk, top row is open-ended.
const merchantBrackets = [
  { 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 }
];

const parseTiered = (tieredPricing) =>
  buildBuylistConfigUpdateSchema(mtg).safeParse({ tieredPricing });

describe('tiered price range validation', () => {
  it('accepts the full merchant bracket table', () => {
    expect(parseTiered({ enabled: true, brackets: merchantBrackets }).success).toBe(true);
  });

  it('accepts an empty brackets array as the clear-the-table signal', () => {
    expect(parseTiered({ enabled: false, brackets: [] }).success).toBe(true);
  });

  it('accepts switching the table off on its own', () => {
    expect(parseTiered({ enabled: false }).success).toBe(true);
  });

  it('rejects enabling the table without any brackets', () => {
    expect(parseTiered({ enabled: true }).success).toBe(false);
    expect(parseTiered({ enabled: true, brackets: [] }).success).toBe(false);
  });

  it('rejects more than 10 brackets', () => {
    const eleven = Array.from({ length: 11 }, (_, i) => ({ minPrice: i, rate: 0.5 }));
    expect(parseTiered({ enabled: true, brackets: eleven }).success).toBe(false);
  });

  it('accepts exactly 10 brackets', () => {
    const ten = Array.from({ length: 10 }, (_, i) => ({ minPrice: i, rate: 0.5 }));
    expect(parseTiered({ enabled: true, brackets: ten }).success).toBe(true);
  });

  it('rejects a first bracket that does not start at 0', () => {
    expect(parseTiered({
      enabled: true,
      brackets: [{ minPrice: 1, rate: 0.5 }, { minPrice: 5, rate: 0.6 }]
    }).success).toBe(false);
  });

  it('rejects descending minPrice values', () => {
    expect(parseTiered({
      enabled: true,
      brackets: [{ minPrice: 0, rate: 0.5 }, { minPrice: 5, rate: 0.6 }, { minPrice: 2, rate: 0.7 }]
    }).success).toBe(false);
  });

  it('rejects duplicate minPrice values', () => {
    expect(parseTiered({
      enabled: true,
      brackets: [{ minPrice: 0, rate: 0.5 }, { minPrice: 5, rate: 0.6 }, { minPrice: 5, rate: 0.7 }]
    }).success).toBe(false);
  });

  it('rejects a bracket that sets both flatAmount and rate', () => {
    expect(parseTiered({
      enabled: true,
      brackets: [{ minPrice: 0, flatAmount: 0.05, rate: 0.5 }]
    }).success).toBe(false);
  });

  it('rejects a bracket that sets neither flatAmount nor rate', () => {
    expect(parseTiered({ enabled: true, brackets: [{ minPrice: 0 }] }).success).toBe(false);
  });

  it('rejects a negative flatAmount', () => {
    expect(parseTiered({
      enabled: true,
      brackets: [{ minPrice: 0, flatAmount: -1 }]
    }).success).toBe(false);
  });

  it('rejects a rate above 1', () => {
    expect(parseTiered({ enabled: true, brackets: [{ minPrice: 0, rate: 1.5 }] }).success).toBe(false);
  });

  it('rejects a negative minPrice', () => {
    expect(parseTiered({ enabled: true, brackets: [{ minPrice: -1, rate: 0.5 }] }).success).toBe(false);
  });

  it('rejects an unknown key inside a bracket', () => {
    expect(parseTiered({
      enabled: true,
      brackets: [{ minPrice: 0, rate: 0.5, maxPrice: 5 }]
    }).success).toBe(false);
  });

  it('rejects an unknown key inside tieredPricing', () => {
    expect(parseTiered({ enabled: true, brackets: merchantBrackets, mode: 'x' }).success).toBe(false);
  });
});
  • [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/schemas/buylistConfig.test.js

Expected: FAIL. The .strict() on the top-level schema rejects the unknown tieredPricing key, so every acceptance test fails with an unrecognized-key issue.

  • [ ] Step 3: Add the schema

In server/schemas/buylistConfig.js, insert after the rateSchema declaration (line 15) and before buildBuylistConfigUpdateSchema:

javascript
// Issue #400's "Expand Your Range" table. The issue caps it at 10 ranges.
const MAX_BRACKETS = 10;

// One row of the payout table. A row pays EITHER a flat dollar amount OR a
// fraction of the basis price -- "calculated based on which field is filled
// out" is enforced here rather than inferred downstream, so a both-set or
// neither-set row is a 400 instead of a silently-resolved guess.
const bracketSchema = z.object({
    minPrice: z.number().min(0, 'minPrice must be >= 0'),
    flatAmount: z.number().min(0, 'flatAmount must be >= 0').optional(),
    rate: rateSchema.optional()
}).strict().refine(
    (b) => (b.flatAmount === undefined) !== (b.rate === undefined),
    { message: 'each bracket must set exactly one of flatAmount or rate' }
);

// Rows store a lower bound only; a row's upper bound is the next row's
// minPrice. That makes gaps and overlaps impossible, but only if the bounds
// ascend strictly (equal bounds make the winning row ambiguous) and the first
// row starts at 0 (otherwise a cheap card falls beneath the table with no rule
// to price it). An empty array is the "clear the table" signal and is allowed.
const bracketsSchema = z.array(bracketSchema)
    .max(MAX_BRACKETS, `at most ${MAX_BRACKETS} price ranges are allowed`)
    .superRefine((brackets, ctx) => {
        if (brackets.length === 0) return;
        if (brackets[0].minPrice !== 0) {
            ctx.addIssue({
                code: 'custom',
                path: [0, 'minPrice'],
                message: 'the first price range must start at 0 so every card is covered'
            });
        }
        for (let i = 1; i < brackets.length; i++) {
            if (brackets[i].minPrice <= brackets[i - 1].minPrice) {
                ctx.addIssue({
                    code: 'custom',
                    path: [i, 'minPrice'],
                    message: 'price range minPrice values must ascend strictly'
                });
            }
        }
    });

Then add this entry to the object returned by buildBuylistConfigUpdateSchema, directly after the stopGap entry:

javascript
        tieredPricing: z.object({
            enabled: z.boolean().optional(),
            brackets: bracketsSchema.optional()
        }).strict().refine(
            // PUT is a partial update with no post-merge validation hook, so
            // this is the only place that can guarantee `enabled: true` never
            // lands on an empty table (which would mean "no pricing rules at
            // all"). The client always sends both fields together.
            (t) => !(t.enabled === true && (t.brackets === undefined || t.brackets.length === 0)),
            { message: 'enabling price ranges requires at least one range' }
        ).optional(),
  • [ ] Step 4: Run the tests to verify they pass
bash
npx vitest run server/schemas/buylistConfig.test.js

Expected: PASS, all tests in the file including the 16 new ones.

  • [ ] Step 5: Lint the changed file
bash
npx eslint server/schemas/buylistConfig.js

Expected: exits 0, no output.

  • [ ] Step 6: Commit
bash
git add server/schemas/buylistConfig.js server/schemas/buylistConfig.test.js
git commit -m "$(cat <<'EOF'
Validate buylist price-range tables on save

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"

Task 3: Bracket pricing in the quote engine ​

Files:

  • Modify: server/services/buylistQuoteService.js:446-472 (applyBuylistRate and the helpers above it)
  • Test: server/services/buylistQuoteService.test.js

Interfaces:

  • Consumes: the tieredPricing config shape from Task 1.
  • Produces: applyBuylistRate({ basisPrice, rarity, condition, quantity }, buylistConfig) keeps its existing signature and return shape { offerPrice, ruleApplied, lineTotal }, and gains ruleApplied values of the form `bracket:${minPrice}` (e.g. bracket:0, bracket:20). resolveBracket(basisPrice, brackets) is a new module-private helper; it is not exported and no other task calls it directly.

Everything downstream is already wired: priceAndRateLine and priceIdentifiedLine both call applyBuylistRate via deps, the public portal reaches it through priceSelectedLines, and BuylistOrder.lines.ruleApplied is an unconstrained String (server/models/BuylistOrder.js:38) with no client-side consumer. No model or route change.

  • [ ] Step 1: Write the failing tests

Append to server/services/buylistQuoteService.test.js:

javascript
describe('applyBuylistRate β€” tiered price ranges (issue #400)', () => {
    // The merchant's real table, plus bulk/stopGap values deliberately set to
    // amounts that WOULD change the answer, so the bypass assertions are real.
    const tieredConfig = {
        defaultRate: 0.40,
        rarityRates: {},
        bulk: { enabled: true, threshold: 0.25, flatPrice: 0.05 },
        stopGap: { enabled: true, threshold: 5.00, rate: 0.50 },
        conditionMultipliers: { nm: 1.0, lp: 0.85, mp: 0.70, hp: 0.50, damaged: 0.25 },
        tieredPricing: {
            enabled: true,
            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 }
            ]
        }
    };

    it('pays the flat amount of the row a basis price falls inside', () => {
        const result = applyBuylistRate({ basisPrice: 1.75, rarity: 'common', condition: 'nm', quantity: 1 }, tieredConfig);
        expect(result.offerPrice).toBe(0.10);
        expect(result.ruleApplied).toBe('bracket:1');
        expect(result.lineTotal).toBe(0.10);
    });

    it('lands on the row whose minPrice it exactly equals, not the row below', () => {
        const result = applyBuylistRate({ basisPrice: 2.00, rarity: 'common', condition: 'nm', quantity: 1 }, tieredConfig);
        expect(result.offerPrice).toBe(0.50);
        expect(result.ruleApplied).toBe('bracket:2');
    });

    it('pays a percentage of the basis price on a rate row', () => {
        const result = applyBuylistRate({ basisPrice: 30.00, rarity: 'common', condition: 'nm', quantity: 2 }, tieredConfig);
        // 30.00 * 0.65 = 19.50, x2 quantity
        expect(result.offerPrice).toBe(19.50);
        expect(result.ruleApplied).toBe('bracket:20');
        expect(result.lineTotal).toBe(39.00);
    });

    it('uses the open-ended top row for any price above it', () => {
        const result = applyBuylistRate({ basisPrice: 4000, rarity: 'common', condition: 'nm', quantity: 1 }, tieredConfig);
        // 4000 * 0.80
        expect(result.offerPrice).toBe(3200);
        expect(result.ruleApplied).toBe('bracket:100');
    });

    it('uses the $0 row for a sub-dollar price, rounding a sub-cent payout up to a penny', () => {
        // round2 is applied per card, exactly as the legacy bulk rule does, so
        // a $0.005 row pays $0.01/card. Pinned deliberately -- see the plan's
        // Global Constraints.
        const result = applyBuylistRate({ basisPrice: 0.03, rarity: 'common', condition: 'nm', quantity: 1000 }, tieredConfig);
        expect(result.offerPrice).toBe(0.01);
        expect(result.ruleApplied).toBe('bracket:0');
        expect(result.lineTotal).toBe(10.00);
    });

    it('lets a rarity override beat a flat-amount row', () => {
        const config = { ...tieredConfig, rarityRates: { mythic: 0.50 } };
        const result = applyBuylistRate({ basisPrice: 2.00, rarity: 'mythic', condition: 'nm', quantity: 1 }, config);
        // 2.00 * 0.50 = 1.00, not the bracket:2 row's flat $0.50
        expect(result.offerPrice).toBe(1.00);
        expect(result.ruleApplied).toBe('rarity:mythic');
    });

    it('lets a rarity override beat a percentage row', () => {
        const config = { ...tieredConfig, rarityRates: { mythic: 0.90 } };
        const result = applyBuylistRate({ basisPrice: 30.00, rarity: 'mythic', condition: 'nm', quantity: 1 }, config);
        // 30.00 * 0.90 = 27.00, not 30.00 * 0.65
        expect(result.offerPrice).toBe(27.00);
        expect(result.ruleApplied).toBe('rarity:mythic');
    });

    it('ignores the bulk rule while the table is on', () => {
        // 0.15 is under bulk.threshold (0.25); the legacy chain would pay a flat 0.05.
        const result = applyBuylistRate({ basisPrice: 0.15, rarity: 'common', condition: 'nm', quantity: 1 }, tieredConfig);
        expect(result.ruleApplied).toBe('bracket:0');
        expect(result.offerPrice).toBe(0.01);
    });

    it('ignores the stop-gap rule while the table is on', () => {
        // 48.50 is over stopGap.threshold (5.00); the legacy chain would pay 50%.
        const result = applyBuylistRate({ basisPrice: 48.50, rarity: 'common', condition: 'nm', quantity: 1 }, tieredConfig);
        // 48.50 * 0.65 = 31.525 -> 31.53
        expect(result.ruleApplied).toBe('bracket:20');
        expect(result.offerPrice).toBe(31.53);
    });

    it('applies the condition multiplier to a percentage row', () => {
        const result = applyBuylistRate({ basisPrice: 30.00, rarity: 'common', condition: 'lp', quantity: 1 }, tieredConfig);
        // 30.00 * 0.65 * 0.85 = 16.575 -> 16.58
        expect(result.offerPrice).toBe(16.58);
    });

    it('applies the condition multiplier to a flat-amount row too, unlike the legacy bulk rule', () => {
        const result = applyBuylistRate({ basisPrice: 4.50, rarity: 'common', condition: 'damaged', quantity: 1 }, tieredConfig);
        // $2.00 flat * 0.25 (damaged) = 0.50
        expect(result.offerPrice).toBe(0.50);
        expect(result.ruleApplied).toBe('bracket:4');
    });

    it('falls back to the legacy chain when the table is enabled but empty', () => {
        const config = { ...tieredConfig, tieredPricing: { enabled: true, brackets: [] } };
        const result = applyBuylistRate({ basisPrice: 2.00, rarity: 'common', condition: 'nm', quantity: 1 }, config);
        // 2.00 * 0.40 default
        expect(result.offerPrice).toBe(0.80);
        expect(result.ruleApplied).toBe('default');
    });

    it('falls back to the legacy chain when the table is populated but switched off', () => {
        const config = { ...tieredConfig, tieredPricing: { ...tieredConfig.tieredPricing, enabled: false } };
        const result = applyBuylistRate({ basisPrice: 0.15, rarity: 'common', condition: 'hp', quantity: 12 }, config);
        expect(result.ruleApplied).toBe('bulk');
        expect(result.offerPrice).toBe(0.05);
    });

    it('still returns a no-price rule when there is no basis price', () => {
        const result = applyBuylistRate({ basisPrice: null, rarity: 'common', condition: 'nm', quantity: 1 }, tieredConfig);
        expect(result.offerPrice).toBe(0);
        expect(result.ruleApplied).toBe('no-price');
        expect(result.lineTotal).toBe(0);
    });

    it('resolves correctly without reordering the brackets array it was handed (Β§5.11)', () => {
        const brackets = [
            { minPrice: 5, rate: 0.60 },
            { minPrice: 0, flatAmount: 0.01 }
        ];
        const config = { ...tieredConfig, tieredPricing: { enabled: true, brackets } };

        const result = applyBuylistRate({ basisPrice: 10, rarity: 'common', condition: 'nm', quantity: 1 }, config);

        expect(result.offerPrice).toBe(6.00);
        expect(result.ruleApplied).toBe('bracket:5');
        expect(brackets[0]).toEqual({ minPrice: 5, rate: 0.60 });
    });
});
  • [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/services/buylistQuoteService.test.js -t "tiered price ranges"

Expected: FAIL. The first test reports expected 0.02 to be 0.1 β€” tieredPricing is ignored, so the legacy chain prices 1.75 at the 0.40 default rate β€” and ruleApplied is 'default' rather than 'bracket:1'.

  • [ ] Step 3: Add resolveBracket above applyBuylistRate

In server/services/buylistQuoteService.js, insert directly before the applyBuylistRate JSDoc block (currently line 446):

javascript
/**
 * Which bracket row covers a basis price: the last row whose minPrice is <= the
 * price. buildBuylistConfigUpdateSchema guarantees the first row starts at 0 and
 * bounds ascend strictly, so a positive basis price always lands somewhere and
 * the final row is open-ended ($100+). Returns null only if the table somehow
 * starts above the price, in which case the caller falls back to the legacy chain.
 *
 * Sorts a COPY: this array comes straight out of the store's resolved buylist
 * config, and Β§5.11 says price-path code never mutates or reorders an input it
 * was handed. The sort is belt-and-braces over the schema's write-time ordering
 * -- it keeps the lookup correct for a hand-edited document too.
 */
function resolveBracket(basisPrice, brackets) {
    const sorted = [...brackets].sort((a, b) => a.minPrice - b.minPrice);
    let found = null;
    for (const bracket of sorted) {
        if (basisPrice < bracket.minPrice) break;
        found = bracket;
    }
    return found;
}
  • [ ] Step 4: Add the bracket branch to applyBuylistRate

Replace the body of applyBuylistRate (currently lines 451-472) with:

javascript
function applyBuylistRate({ basisPrice, rarity, condition, quantity }, buylistConfig) {
    if (basisPrice == null) {
        return { offerPrice: 0, ruleApplied: 'no-price', lineTotal: 0 };
    }

    const conditionMultiplier = buylistConfig.conditionMultipliers?.[condition] ?? 1.0;

    // The "Expand Your Range" table (issue #400) owns the whole price curve when
    // it's on: bulk, defaultRate and stopGap below are all bypassed, because the
    // merchant's own table already has a bulk row at the bottom and an
    // open-ended row at the top. An empty/absent table falls through to the
    // legacy chain unchanged -- the schema forbids enabling with no rows, and
    // this is the belt to that braces.
    const brackets = buylistConfig.tieredPricing?.enabled
        ? (buylistConfig.tieredPricing.brackets || [])
        : [];
    const bracket = brackets.length > 0 ? resolveBracket(basisPrice, brackets) : null;

    if (bracket) {
        // A rarity override still wins (issue #400: "rarity/card overrides
        // continue to apply"). It's a fraction of the basis price, so it
        // replaces the row's whole payout rather than scaling a flat amount.
        const rarityRate = buylistConfig.rarityRates?.[rarity];
        if (rarityRate != null) {
            const offerPrice = round2(basisPrice * rarityRate * conditionMultiplier);
            return { offerPrice, ruleApplied: `rarity:${rarity}`, lineTotal: round2(offerPrice * quantity) };
        }
        const payout = bracket.flatAmount != null ? bracket.flatAmount : basisPrice * bracket.rate;
        const offerPrice = round2(payout * conditionMultiplier);
        return { offerPrice, ruleApplied: `bracket:${bracket.minPrice}`, lineTotal: round2(offerPrice * quantity) };
    }

    if (buylistConfig.bulk?.enabled && basisPrice < buylistConfig.bulk.threshold) {
        const offerPrice = round2(buylistConfig.bulk.flatPrice);
        return { offerPrice, ruleApplied: 'bulk', lineTotal: round2(offerPrice * quantity) };
    }

    let rate = buylistConfig.rarityRates?.[rarity] ?? buylistConfig.defaultRate;
    let ruleApplied = buylistConfig.rarityRates?.[rarity] != null ? `rarity:${rarity}` : 'default';

    if (buylistConfig.stopGap?.enabled && basisPrice > buylistConfig.stopGap.threshold) {
        rate = buylistConfig.stopGap.rate;
        ruleApplied = 'stopGap';
    }

    const offerPrice = round2(basisPrice * rate * conditionMultiplier);
    return { offerPrice, ruleApplied, lineTotal: round2(offerPrice * quantity) };
}

Note the one structural change to the legacy path: conditionMultiplier is now computed once at the top instead of just before the final round2. The legacy arithmetic is otherwise byte-identical, and the existing legacy tests in this file prove it.

  • [ ] Step 5: Run the whole file's tests to verify new and legacy both pass
bash
npx vitest run server/services/buylistQuoteService.test.js

Expected: PASS, all tests. The five pre-existing applyBuylistRate legacy tests (rarity rate, default rate, stop-gap, bulk, no-price) must still pass untouched β€” if any fails, the legacy chain was altered and the change is wrong.

  • [ ] Step 6: Lint the changed file
bash
npx eslint server/services/buylistQuoteService.js

Expected: exits 0, no output. If security/detect-object-injection fires on rarityRates?.[rarity], mirror the existing disable comment on applySealedBuylistRate's categoryRates?.[category] line rather than restructuring the lookup.

  • [ ] Step 7: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "$(cat <<'EOF'
Price buylist cards from the merchant's bracketed payout table

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"

Task 4: Expand Your Range settings UI ​

Files:

  • Modify: client/src/pages/buylist/BuylistSettingsTab.jsx
  • Test: client/src/pages/buylist/BuylistSettingsTab.test.jsx

Interfaces:

  • Consumes: GET /api/buylist/config now returns config.tieredPricing (Task 1); PUT /api/buylist/config accepts it (Task 2).
  • Produces: nothing other tasks depend on. This is the last implementation task.

The component holds bracket rows as strings (raw input values) and converts on save, matching how rarityRates and the bulk/stop-gap numbers are already handled in this file.

  • [ ] Step 1: Write the failing tests

Append to client/src/pages/buylist/BuylistSettingsTab.test.jsx:

javascript
describe('Expand Your Range', () => {
  const tieredConfigResponse = (tieredPricing) => ({
    data: {
      ...configResponse.data,
      config: { ...configResponse.data.config, tieredPricing }
    }
  });

  const mockConfig = (tieredPricing) => {
    api.get.mockImplementation((url) =>
      url.startsWith('/games')
        ? Promise.resolve(gamesResponse)
        : Promise.resolve(tieredConfigResponse(tieredPricing))
    );
  };

  it('hides the range table until the switch is turned on', async () => {
    mockConfig({ enabled: false, brackets: [] });
    render(<BuylistSettingsTab />);

    await screen.findByLabelText(/use price ranges/i);
    expect(screen.queryByLabelText(/row 1 flat amount/i)).not.toBeInTheDocument();
  });

  it('seeds a first $0 row when the switch is turned on with an empty table', async () => {
    mockConfig({ enabled: false, brackets: [] });
    render(<BuylistSettingsTab />);

    fireEvent.click(await screen.findByLabelText(/use price ranges/i));

    expect(screen.getByLabelText(/row 1 starting price/i)).toHaveValue(0);
    expect(screen.getByLabelText(/row 1 flat amount/i)).toHaveValue(null);
  });

  it('renders stored rows with derived range labels', async () => {
    mockConfig({
      enabled: true,
      brackets: [
        { minPrice: 0, flatAmount: 0.005 },
        { minPrice: 1, flatAmount: 0.1 },
        { minPrice: 5, rate: 0.6 }
      ]
    });
    render(<BuylistSettingsTab />);

    expect(await screen.findByText('Under $1.00')).toBeInTheDocument();
    expect(screen.getByText('$1.00 – $4.99')).toBeInTheDocument();
    expect(screen.getByText('$5.00+')).toBeInTheDocument();
    // Fractions arrive over the wire, percentages show in the input.
    expect(screen.getByLabelText(/row 3 percentage/i)).toHaveValue(60);
  });

  it('clears the percentage when a flat amount is typed, and vice versa', async () => {
    mockConfig({ enabled: true, brackets: [{ minPrice: 0, rate: 0.5 }] });
    render(<BuylistSettingsTab />);

    const flat = await screen.findByLabelText(/row 1 flat amount/i);
    fireEvent.change(flat, { target: { value: '0.05' } });
    expect(screen.getByLabelText(/row 1 percentage/i)).toHaveValue(null);

    fireEvent.change(screen.getByLabelText(/row 1 percentage/i), { target: { value: '40' } });
    expect(screen.getByLabelText(/row 1 flat amount/i)).toHaveValue(null);
  });

  it('adds rows up to the 10-row cap and removes them again', async () => {
    mockConfig({ enabled: true, brackets: [{ minPrice: 0, rate: 0.5 }] });
    render(<BuylistSettingsTab />);

    const add = await screen.findByRole('button', { name: /add range/i });
    for (let i = 0; i < 9; i++) fireEvent.click(add);

    expect(screen.getByLabelText(/row 10 starting price/i)).toBeInTheDocument();
    expect(add).toBeDisabled();

    fireEvent.click(screen.getByRole('button', { name: /remove row 10/i }));
    expect(screen.queryByLabelText(/row 10 starting price/i)).not.toBeInTheDocument();
    expect(add).not.toBeDisabled();
  });

  it('blocks saving a row with neither a dollar amount nor a percentage', async () => {
    mockConfig({ enabled: true, brackets: [{ minPrice: 0, rate: 0.5 }] });
    render(<BuylistSettingsTab />);

    fireEvent.click(await screen.findByRole('button', { name: /add range/i }));

    expect(screen.getByText(/row 2 needs either a dollar amount or a percentage/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /save settings/i })).toBeDisabled();
  });

  it('disables the bulk and stop-gap fields while the table is on', async () => {
    mockConfig({ enabled: true, brackets: [{ minPrice: 0, rate: 0.5 }] });
    render(<BuylistSettingsTab />);

    expect(await screen.findByLabelText(/bulk threshold/i)).toBeDisabled();
    expect(screen.getByLabelText(/stop-gap rate/i)).toBeDisabled();
    expect(screen.getByText(/replaced by your price ranges/i)).toBeInTheDocument();
  });

  it('sends dollars for flat rows and fractions for percentage rows on save', async () => {
    mockConfig({
      enabled: true,
      brackets: [{ minPrice: 0, flatAmount: 0.005 }, { minPrice: 5, rate: 0.6 }]
    });
    render(<BuylistSettingsTab />);

    await screen.findByLabelText(/row 2 percentage/i);
    fireEvent.change(screen.getByLabelText(/row 2 percentage/i), { target: { value: '65' } });
    fireEvent.click(screen.getByRole('button', { name: /save settings/i }));

    await waitFor(() => expect(api.put).toHaveBeenCalledWith(
      '/buylist/config?game=mtg',
      expect.objectContaining({
        tieredPricing: {
          enabled: true,
          brackets: [{ minPrice: 0, flatAmount: 0.005 }, { minPrice: 5, rate: 0.65 }]
        }
      })
    ));
  });

  it('keeps the rows on save after the switch is turned off, so they can be turned back on', async () => {
    mockConfig({ enabled: true, brackets: [{ minPrice: 0, rate: 0.5 }] });
    render(<BuylistSettingsTab />);

    fireEvent.click(await screen.findByLabelText(/use price ranges/i));
    fireEvent.click(screen.getByRole('button', { name: /save settings/i }));

    await waitFor(() => expect(api.put).toHaveBeenCalledWith(
      '/buylist/config?game=mtg',
      expect.objectContaining({
        tieredPricing: { enabled: false, brackets: [{ minPrice: 0, rate: 0.5 }] }
      })
    ));
  });
});
  • [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistSettingsTab.test.jsx -t "Expand Your Range"

Expected: FAIL β€” findByLabelText(/use price ranges/i) times out because the card does not exist yet.

  • [ ] Step 3: Add the module-level helpers

In client/src/pages/buylist/BuylistSettingsTab.jsx, insert after the toFraction helper (line 8):

javascript
// Mirrors the cap in server/schemas/buylistConfig.js (MAX_BRACKETS). The server
// is what enforces it; this only stops the merchant from building a table the
// save would reject (rule 7).
const MAX_BRACKETS = 10;

// The stored model is lower bounds only -- a row's upper bound is the next
// row's lower bound (see the design doc's "Why lower bounds only"). Deriving
// the label here keeps the table reading the way merchants write it ("$1–1.99")
// without storing a redundant, gap-prone second number.
function bracketRangeLabel(brackets, index) {
  const min = Number(brackets[index].minPrice || 0);
  const next = brackets[index + 1];
  if (!next) return index === 0 ? 'All cards' : `$${min.toFixed(2)}+`;
  const nextMin = Number(next.minPrice || 0);
  if (index === 0) return `Under $${nextMin.toFixed(2)}`;
  return `$${min.toFixed(2)} – $${(nextMin - 0.01).toFixed(2)}`;
}

// Mirrors the rules in server/schemas/buylistConfig.js (bracketSchema /
// bracketsSchema). The server rejects all of these with a 400; this exists so a
// merchant sees the problem next to the Save button instead of after clicking it.
function validateBrackets(brackets) {
  const errors = [];
  brackets.forEach((b, i) => {
    const hasFlat = b.flatAmount !== '' && b.flatAmount !== undefined;
    const hasRate = b.rate !== '' && b.rate !== undefined;
    if (hasFlat === hasRate) {
      errors.push(`Row ${i + 1} needs either a dollar amount or a percentage, not both.`);
    }
    if (b.minPrice === '' || Number.isNaN(Number(b.minPrice))) {
      errors.push(`Row ${i + 1} needs a starting price.`);
    } else if (i > 0 && Number(b.minPrice) <= Number(brackets[i - 1].minPrice)) {
      errors.push(`Row ${i + 1} must start above row ${i}.`);
    }
  });
  if (brackets.length > 0 && Number(brackets[0].minPrice) !== 0) {
    errors.push('The first row must start at $0 so every card is covered.');
  }
  return errors;
}
  • [ ] Step 4: Load the stored table into form state

In load(), add directly after the const sealed = ... line:

javascript
      const tiered = c.tieredPricing || { enabled: false, brackets: [] };

and add these two entries to the setForm({ ... }) object, after sealedCategoryRates:

javascript
        tieredEnabled: tiered.enabled,
        // Rows are held as raw input strings and converted on save, the same
        // way rarityRates and the bulk/stop-gap numbers already are.
        brackets: (tiered.brackets || []).map((b) => ({
          minPrice: String(b.minPrice),
          flatAmount: b.flatAmount != null ? String(b.flatAmount) : '',
          rate: b.rate != null ? String(toPct(b.rate)) : ''
        }))
  • [ ] Step 5: Add the row-editing handlers

In the component body, insert after the existing const set = (key) => ... line:

javascript
  // Every handler builds a new array and a new row object -- never mutates a
  // row in place (Β§5.11).
  const updateBracket = (index, patch) => setForm((f) => ({
    ...f,
    brackets: f.brackets.map((b, i) => (i === index ? { ...b, ...patch } : b))
  }));

  const addBracket = () => setForm((f) => {
    if (f.brackets.length >= MAX_BRACKETS) return f;
    const last = f.brackets[f.brackets.length - 1];
    const nextMin = last ? String(Number(last.minPrice || 0) + 1) : '0';
    return { ...f, brackets: [...f.brackets, { minPrice: nextMin, flatAmount: '', rate: '' }] };
  });

  const removeBracket = (index) => setForm((f) => ({
    ...f,
    brackets: f.brackets.filter((_, i) => i !== index)
  }));

  // Turning the table on with nothing in it would fail the server's "enabling
  // price ranges requires at least one range" rule, so seed the $0 row. Turning
  // it OFF keeps the rows, so a merchant can toggle back on without retyping.
  const toggleTiered = (checked) => setForm((f) => ({
    ...f,
    tieredEnabled: checked,
    brackets: checked && f.brackets.length === 0
      ? [{ minPrice: '0', flatAmount: '', rate: '' }]
      : f.brackets
  }));

  const bracketErrors = form ? validateBrackets(form.brackets) : [];
  • [ ] Step 6: Send the table on save

In save(), add this entry to the body object, after stopGap:

javascript
        tieredPricing: {
          enabled: form.tieredEnabled,
          brackets: form.brackets.map((b) => ({
            minPrice: Number(b.minPrice),
            // Exactly one of the two, matching bracketSchema's XOR refine. The
            // UI already guarantees only one field is non-empty per row.
            ...(b.flatAmount !== '' ? { flatAmount: Number(b.flatAmount) } : { rate: toFraction(b.rate) })
          }))
        },
  • [ ] Step 7: Render the card

Insert this <Card> immediately before the existing Overrides card in the JSX:

jsx
      <Card>
        <Card.Header>
          <Card.Title>Expand Your Range</Card.Title>
          <Card.Description>Pay different amounts depending on what a card is worth. Replaces the Bulk and Stop-gap rules below.</Card.Description>
        </Card.Header>
        <Card.Content className="pt-0">
          <div className="flex items-center justify-between gap-3 py-2 border-b-2 border-muted">
            <label htmlFor="tiered-enabled" className="font-semibold text-sm">Use price ranges</label>
            <Switch id="tiered-enabled" checked={form.tieredEnabled} onCheckedChange={toggleTiered} />
          </div>

          {form.tieredEnabled && (
            <div className="space-y-2 pt-3">
              {form.brackets.map((b, i) => (
                <div key={i} className="flex items-center gap-2 flex-wrap">
                  <span className="text-sm font-semibold w-36">{bracketRangeLabel(form.brackets, i)}</span>
                  <span className="text-sm">from $</span>
                  <Input
                    aria-label={`Row ${i + 1} starting price`}
                    type="number" min={0} step="0.01"
                    className="w-24 text-right"
                    disabled={i === 0}
                    value={b.minPrice}
                    onChange={(e) => updateBracket(i, { minPrice: e.target.value })}
                  />
                  <span className="text-sm">pay $</span>
                  <Input
                    aria-label={`Row ${i + 1} flat amount`}
                    type="number" min={0} step="0.005"
                    className="w-24 text-right"
                    value={b.flatAmount}
                    onChange={(e) => updateBracket(i, { flatAmount: e.target.value, rate: '' })}
                  />
                  <span className="text-sm">or</span>
                  <Input
                    aria-label={`Row ${i + 1} percentage`}
                    type="number" min={0} max={100}
                    className="w-20 text-right"
                    value={b.rate}
                    onChange={(e) => updateBracket(i, { rate: e.target.value, flatAmount: '' })}
                  />
                  <span className="text-sm font-bold">%</span>
                  {i > 0 && (
                    <Button variant="outline" size="sm" aria-label={`Remove row ${i + 1}`} onClick={() => removeBracket(i)}>
                      Remove
                    </Button>
                  )}
                </div>
              ))}
              <Button variant="outline" size="sm" onClick={addBracket} disabled={form.brackets.length >= MAX_BRACKETS}>
                Add range
              </Button>
            </div>
          )}

          {bracketErrors.length > 0 && (
            <ul className="pt-3 text-sm font-semibold space-y-1">
              {bracketErrors.map((e) => <li key={e}>{e}</li>)}
            </ul>
          )}
        </Card.Content>
      </Card>
  • [ ] Step 8: Disable the Overrides card while the table is on

Change the Overrides <Card> opening tag and header to:

jsx
      <Card className={form.tieredEnabled ? 'opacity-50' : undefined}>
        <Card.Header>
          <Card.Title>Overrides</Card.Title>
          {form.tieredEnabled && (
            <Card.Description>Replaced by your price ranges above. Turn off Expand Your Range to use these again.</Card.Description>
          )}
        </Card.Header>

and add disabled={form.tieredEnabled} to all four inputs inside it: bulk-threshold, bulk-flat, stopgap-threshold, stopgap-rate.

  • [ ] Step 9: Block Save while a row is incomplete

Change the Save button to:

jsx
      <Button onClick={save} disabled={saving || bracketErrors.length > 0}>
        {saving ? 'Saving…' : 'Save settings'}
      </Button>
  • [ ] Step 10: Run the client tests
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistSettingsTab.test.jsx

Expected: PASS, all tests. The pre-existing tests in this file must still pass β€” they use a fixture with no tieredPricing, which the || { enabled: false, brackets: [] } fallback in Step 4 handles.

  • [ ] Step 11: Lint the changed file
bash
npx eslint client/src/pages/buylist/BuylistSettingsTab.jsx

Expected: exits 0. If the key={i} on the row <div> triggers a rule, keep the index key β€” rows are positional and reorder only by add/remove at the end, so index is the correct identity here; add an eslint-disable with that reason rather than inventing a synthetic id.

  • [ ] Step 12: Commit
bash
git add client/src/pages/buylist/BuylistSettingsTab.jsx client/src/pages/buylist/BuylistSettingsTab.test.jsx
git commit -m "$(cat <<'EOF'
Let merchants set buylist payouts by card value range

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"

Task 5: Full verification gate ​

Files: none modified unless a check fails.

This task exists because Β§7 of CLAUDE.md defines "done" as commands that ran and passed, and because CI on this repo runs security scans only β€” local npm test is the sole verification gate.

  • [ ] Step 1: Run the full server suite
bash
npm test

Expected: exits 0. Pay attention to server/routes/api.test.js and server/services/publicBuylistService.test.js β€” neither was modified, and both exercise paths that reach applyBuylistRate.

  • [ ] Step 2: Run the full client suite
bash
npm run test:client

Expected: exits 0.

  • [ ] Step 3: Lint the whole repo
bash
npm run lint

Expected: exits 0 with zero new warnings versus main.

  • [ ] Step 4: Build the client
bash
npm run build

Expected: exits 0.

  • [ ] Step 5: Confirm no identity defaults crept in (Β§5.5)
bash
git diff origin/main...HEAD -- server client | grep -nE "\|\| 'mtg'|\?\? 'mtg'|= 'mtg'"

Expected: no output. (grep exiting 1 with no matches is the pass condition.)

  • [ ] Step 6: Confirm no raw Shopify calls were introduced (rule 6)
bash
git diff origin/main...HEAD -- server client | grep -n "myshopify.com"

Expected: no output.

  • [ ] Step 7: Write the parity statement (Β§5.1)

State in the PR description and the final summary, explicitly naming each registered plugin:

  • mtg β€” mirrored: inherits tieredPricing from BASE_BUYLIST_DEFAULTS; rarityRates precedence validated against the mtg plugin's rarity vocabulary.
  • pokemon β€” mirrored: same shared default, same code path, no per-game branch.
  • riftbound β€” mirrored: same shared default, same code path, no per-game branch.
  • sealed β€” exempt by design: keeps sealed.defaultRate + sealed.categoryRates and applySealedBuylistRate, untouched by this change. Rationale is in the design doc.

No new persisted Mongoose sub-document field was added (Store.buylistConfig is Mixed; BuylistOrder.lines.ruleApplied is an unconstrained String), so Β§5.2's round-trip-test requirement does not apply. No new data-matching literals were introduced, so Β§5.4 does not apply. No aggregations were added, so Β§5.6 does not apply.

  • [ ] Step 8: Commit nothing; report

This task produces no commit. Report the actual output of steps 1-6 β€” not a summary of what they should have produced.


Notes for the implementer ​

Why lower bounds only. The merchant writes $1–1.99, $2–2.99, which has a gap at $1.995. Storing both bounds means validating overlaps and defining a fallback for every gap. Storing only minPrice makes both impossible; the UI shows the derived range so it still reads the way the merchant wrote it.

Why bracket:<minPrice> and not bracket:<index>. ruleApplied is persisted on the order line. An index goes stale the moment a merchant inserts a row above it; the lower bound stays meaningful.

Why the schema enforces "enabling requires rows" rather than the service. PUT /api/buylist/config is a partial update, and applyGameBuylistConfigUpdate has no post-merge validation hook. Adding one is a larger change than this feature needs, and the client always sends enabled and brackets together.

What is deliberately not in scope. Brackets for sealed products; per-card (rather than per-rarity) overrides; importing a table between games or stores; any change to how basisPrice itself is resolved.