Appearance
Smart Buylist Phase 1 Implementation Plan β Model, Config, Settings UI β
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: Land the Smart Buylist foundation: the BuylistOrder model, per-game Store.buylistConfig with a resolution service, GET/PUT config endpoints, and a merchant-facing Buylist Settings page reachable from the existing nav.
Architecture: Mirrors the proven pricingConfig pattern exactly β Store.buylistConfig is a Mixed sub-document keyed by game id, resolved through a new buylistConfigService that builds defaults and deep-merges stored overrides; rarity vocabulary comes from game plugins, never hardcoded. BuylistOrder is a fully-typed per-store collection modeled on SyncJob.
Tech Stack: Node 24 / Express / Mongoose (CommonJS), Zod, Vitest (ESM tests), React 18 + retroui + Tailwind 4.
Spec: docs/superpowers/specs/2026-07-14-smart-buylist-customer-intake-design.md (Phase 1 of 6). Later phases (quote engine, public API/portal, kiosk auth, theme extension, payouts) get their own plans.
Global Constraints β
- Server code is CommonJS (
require); ALL test files are ESM (import) β including tests for CommonJS modules. gameis never defaulted anywhere (CLAUDE.md Β§5.5): missing/unsupported β 400 or throw. Do NOT copy thereq.query.game || 'mtg'pattern from the legacy pricing-config routes atserver/routes/api.js:1707,1733.- Rarity/finish/condition literals come from real sources only (Β§5.4): rarity keys from
plugin.rarities(server/plugins/mtg/index.js:24,server/plugins/pokemon/index.js:34), condition keys['nm','lp','mp','hp','damaged']fromserver/schemas/pricingConfig.jsVALID_CONDITIONS. - Every new persisted field is declared field-by-field in a Mongoose schema and covered by a round-trip test (Β§5.2), pattern:
server/models/ShopifyPokemonProductVariant.test.js. - After writing
store.buylistConfig, ALWAYS callstore.markModified('buylistConfig')(Mixed type β Mongoose can't detect nested writes). - Client UI uses retroui components from the barrel:
import { Button, Card, ... } from '../../components/retroui'; class merging viacn()fromlib/utils. - Coverage β₯70% on all four metrics for every modified file;
npm run lintclean; Husky pre-commit runs ESLint β never--no-verify. - Commit messages: one imperative sentence, merchant-visible outcome, sentence case, no trailing period.
- Game parity (Β§5.1): this phase is plugin-driven by construction β rarity vocabularies resolve per plugin, so all registered games (mtg, pokemon, riftbound) are mirrored by construction with no per-game branching. Riftbound gained inventory sync on main (PRs #355β357) mid-implementation, so it is included in the buylist settings UI rather than exempted. State this in the PR description.
Task 0: Preflight β
Files: none (environment only)
- [ ] Step 1: Branch from up-to-date main (Β§5.10)
bash
git fetch origin
git checkout -b claude/buylist-phase-1 origin/main- [ ] Step 2: Install deps and verify the suites run green before touching anything
bash
npm run install:all
npm test 2>&1 | tail -5 # expect: all passing
npm run test:client 2>&1 | tail -5Known gotcha: in a fresh worktree npm run test:client can fail on a missing @testing-library/dom (undeclared peer dep). If so: cd client && npm install --no-save @testing-library/dom and re-run.
Task 1: BuylistOrder model β
Files:
- Create:
server/models/BuylistOrder.js - Test:
server/models/BuylistOrder.test.js
Interfaces:
Produces: Mongoose model
BuylistOrder(collectionbuylist_orders) with fieldsshop,game,source,status,customer{email,name},claimTokenHash,kioskDeviceId,lines[],payout{}, timestamps. Later phases (quote engine, public API) construct and query this model.[ ] Step 1: Write the failing round-trip test
server/models/BuylistOrder.test.js:
javascript
/**
* Round-trip tests for BuylistOrder (CLAUDE.md Β§5.2): every persisted field
* must be declared in the schema or strict mode silently drops it. Each
* assertion here fails if its schema line is deleted.
*/
import { describe, it, expect, beforeEach } from 'vitest';
let BuylistOrder;
beforeEach(async () => {
const module = await import('./BuylistOrder.js');
BuylistOrder = module.default;
});
const validDoc = {
shop: 'test-store.myshopify.com',
game: 'mtg',
source: 'kiosk',
customer: { email: 'customer@example.com', name: 'Sara C.' },
claimTokenHash: 'a'.repeat(64),
kioskDeviceId: 'counter-ipad-1',
lines: [{
rawLine: '1 Ragavan, Nimble Pilferer [MH2]',
matchStatus: 'matched',
cardUuid: 'abc-123',
cardName: 'Ragavan, Nimble Pilferer',
setCode: 'MH2',
collectorNumber: '138',
finish: 'normal', // MTG plugin finish vocabulary β normal/foil/etched, NOT 'nonfoil'
condition: 'nm',
quantity: 1,
basisPrice: 48.50,
ruleApplied: 'stopGap',
offerPrice: 24.25,
included: true
}],
payout: { method: 'store_credit', cashTotal: 24.25, creditTotal: 30.31, creditBonus: 0.25 }
};
describe('BuylistOrder model', () => {
it('accepts a valid document', () => {
const doc = new BuylistOrder(validDoc);
expect(doc.validateSync()).toBeUndefined();
});
it('round-trips every top-level field', () => {
const doc = new BuylistOrder(validDoc);
expect(doc.shop).toBe('test-store.myshopify.com');
expect(doc.game).toBe('mtg');
expect(doc.source).toBe('kiosk');
expect(doc.status).toBe('pending'); // default
expect(doc.customer.email).toBe('customer@example.com');
expect(doc.customer.name).toBe('Sara C.');
expect(doc.claimTokenHash).toBe('a'.repeat(64));
expect(doc.kioskDeviceId).toBe('counter-ipad-1');
expect(doc.payout.method).toBe('store_credit');
expect(doc.payout.creditBonus).toBe(0.25);
});
it('round-trips lines[] as a real array with every line field intact', () => {
const doc = new BuylistOrder(validDoc);
expect(Array.isArray(doc.lines)).toBe(true);
const line = doc.lines[0];
expect(line.rawLine).toBe('1 Ragavan, Nimble Pilferer [MH2]');
expect(line.matchStatus).toBe('matched');
expect(line.cardUuid).toBe('abc-123');
expect(line.cardName).toBe('Ragavan, Nimble Pilferer');
expect(line.setCode).toBe('MH2');
expect(line.collectorNumber).toBe('138');
expect(line.finish).toBe('normal');
expect(line.condition).toBe('nm');
expect(line.quantity).toBe(1);
expect(line.basisPrice).toBe(48.50);
expect(line.ruleApplied).toBe('stopGap');
expect(line.offerPrice).toBe(24.25);
expect(line.included).toBe(true);
});
it('rejects a missing game β no default is ever applied (Β§5.5)', () => {
const { game, ...withoutGame } = validDoc;
const doc = new BuylistOrder(withoutGame);
const error = doc.validateSync();
expect(error?.errors?.game).toBeDefined();
});
it('rejects unknown source and status values', () => {
expect(new BuylistOrder({ ...validDoc, source: 'carrier-pigeon' }).validateSync()?.errors?.source).toBeDefined();
expect(new BuylistOrder({ ...validDoc, status: 'lost' }).validateSync()?.errors?.status).toBeDefined();
});
});- [ ] Step 2: Run the test to verify it fails
bash
npx vitest run server/models/BuylistOrder.test.jsExpected: FAIL β Cannot find module './BuylistOrder.js'
- [ ] Step 3: Write the model
server/models/BuylistOrder.js:
javascript
const mongoose = require('mongoose');
// One line of a customer's sell list. Prices are snapshotted at quote time
// so an accepted offer doesn't drift with the market afterwards.
const buylistLineSchema = new mongoose.Schema({
rawLine: { type: String, required: true },
matchStatus: { type: String, enum: ['matched', 'unmatched'], required: true },
cardUuid: String,
cardName: String,
setCode: String,
collectorNumber: String,
// Plugin finish vocabulary (e.g. MTG: normal/foil/etched β plugin.finishes)
finish: String,
// Condition keys shared with pricingConfig conditionVariants (schemas/pricingConfig.js)
condition: { type: String, enum: ['nm', 'lp', 'mp', 'hp', 'damaged'], default: 'nm' },
quantity: { type: Number, required: true, min: 1 },
basisPrice: Number,
ruleApplied: String,
offerPrice: Number,
included: { type: Boolean, default: true }
}, { _id: false });
const buylistOrderSchema = new mongoose.Schema({
shop: { type: String, required: true, index: true },
// Never defaulted β a missing game is a bug to surface (CLAUDE.md Β§5.5)
game: { type: String, required: true, index: true },
source: { type: String, enum: ['merchant', 'kiosk', 'storefront'], required: true },
status: {
type: String,
enum: ['pending', 'offer_sent', 'accepted', 'declined', 'paid'],
default: 'pending',
index: true
},
customer: {
email: { type: String, required: true },
name: String
},
// Hash only β the claim token itself is returned once to the customer
claimTokenHash: String,
kioskDeviceId: String,
lines: { type: [buylistLineSchema], default: [] },
payout: {
method: { type: String, enum: ['store_credit', 'cash'], default: 'store_credit' },
cashTotal: Number,
creditTotal: Number,
creditBonus: Number
}
}, { timestamps: true, collection: 'buylist_orders' });
buylistOrderSchema.index({ shop: 1, status: 1, createdAt: -1 });
module.exports = mongoose.model('BuylistOrder', buylistOrderSchema);- [ ] Step 4: Run the test to verify it passes
bash
npx vitest run server/models/BuylistOrder.test.jsExpected: PASS (5 tests)
- [ ] Step 5: Commit
bash
git add server/models/BuylistOrder.js server/models/BuylistOrder.test.js
git commit -m "Add BuylistOrder model for tracking customer buy orders"Task 2: Store.buylistConfig field β
Files:
- Modify:
server/models/Store.js(insert directly after thepricingConfigfield, ~line 95) - Create:
server/models/Store.buylistConfig.test.js
Interfaces:
Produces:
store.buylistConfig(Mixed, default{}), keyed by game id. Consumed by Task 3's service.[ ] Step 1: Write the failing round-trip test
server/models/Store.buylistConfig.test.js:
javascript
/**
* Round-trip test for Store.buylistConfig (CLAUDE.md Β§5.2). buylistConfig is
* Mixed (per-game rate vocabularies are plugin-owned), but the FIELD itself
* must be declared or strict mode drops it on write. This fails if the
* buylistConfig schema line is deleted from Store.js.
*/
import { describe, it, expect, beforeEach } from 'vitest';
let Store;
beforeEach(async () => {
const module = await import('./Store.js');
Store = module.default;
});
describe('Store.buylistConfig', () => {
it('round-trips a per-game buylist config through the schema', () => {
const doc = new Store({
shop: 'test-store.myshopify.com',
accessToken: 'token',
scope: 'read_products',
buylistConfig: { mtg: { enabled: true, defaultRate: 0.4 } }
});
expect(doc.validateSync()).toBeUndefined();
expect(doc.buylistConfig).toBeDefined();
expect(doc.buylistConfig.mtg.enabled).toBe(true);
expect(doc.buylistConfig.mtg.defaultRate).toBe(0.4);
});
it('defaults to an empty object', () => {
const doc = new Store({
shop: 'test-store.myshopify.com',
accessToken: 'token',
scope: 'read_products'
});
expect(doc.buylistConfig).toEqual({});
});
});- [ ] Step 2: Run the test to verify it fails
bash
npx vitest run server/models/Store.buylistConfig.test.jsExpected: FAIL β doc.buylistConfig is undefined (field not declared yet)
- [ ] Step 3: Declare the field in Store.js
In server/models/Store.js, immediately after the closing brace of the pricingConfig field (after ~line 95, the default: {} + },), add:
javascript
// Per-game buylist configuration, keyed by game id (mtg, pokemon, ...).
// Mixed for the same reason as pricingConfig above: rate vocabularies are
// plugin-owned. Resolve via buylistConfigService.getGameBuylistConfig;
// ALWAYS call markModified('buylistConfig') after writing.
buylistConfig: {
type: mongoose.Schema.Types.Mixed,
default: {}
},- [ ] Step 4: Run the test to verify it passes
bash
npx vitest run server/models/Store.buylistConfig.test.jsExpected: PASS (2 tests)
- [ ] Step 5: Commit
bash
git add server/models/Store.js server/models/Store.buylistConfig.test.js
git commit -m "Add per-game buylistConfig storage to the Store model"Task 3: buylistConfigService β
Files:
- Create:
server/services/buylistConfigService.js - Test:
server/services/buylistConfigService.test.js
Interfaces:
Consumes:
deepMerge(exported byserver/services/pricingConfigService.js),getPlugin(gameId)fromserver/plugins(throws on unsupported game).Produces (all consumed by Task 5's routes and later phases):
getDefaultGameBuylistConfig(gameId) β config objectgetGameBuylistConfig(store, gameId) β config object(defaults deep-merged with stored overrides)applyGameBuylistConfigUpdate(store, gameId, update) β merged config(writesstore.buylistConfig[gameId], callsmarkModified)buildBuylistConfigResponse(store, gameId) β { game, rarities: [{id,name}], config }
[ ] Step 1: Write the failing tests
server/services/buylistConfigService.test.js:
javascript
import { describe, it, expect, vi } from 'vitest';
import {
getDefaultGameBuylistConfig,
getGameBuylistConfig,
applyGameBuylistConfigUpdate,
buildBuylistConfigResponse
} from './buylistConfigService.js';
const fakeStore = (buylistConfig = {}) => ({
buylistConfig,
markModified: vi.fn()
});
describe('getDefaultGameBuylistConfig', () => {
it('returns the base defaults for a supported game', () => {
const config = getDefaultGameBuylistConfig('mtg');
expect(config.enabled).toBe(false);
expect(config.priceBasis).toBe('market');
expect(config.defaultRate).toBe(0.40);
expect(config.bulk).toEqual({ enabled: true, threshold: 0.25, flatPrice: 0.05 });
expect(config.stopGap).toEqual({ enabled: true, threshold: 5.00, rate: 0.50 });
expect(config.conditionMultipliers).toEqual({ nm: 1.0, lp: 0.85, mp: 0.70, hp: 0.50, damaged: 0.25 });
expect(config.storeCreditBonus).toBe(0.25);
expect(config.instantQuote).toEqual({ web: true, kiosk: true });
});
it('returns a fresh object graph on every call (no shared references)', () => {
const a = getDefaultGameBuylistConfig('mtg');
const b = getDefaultGameBuylistConfig('mtg');
a.bulk.threshold = 999;
expect(b.bulk.threshold).toBe(0.25);
});
it('throws for an unsupported game β no silent fallback (Β§5.5)', () => {
expect(() => getDefaultGameBuylistConfig('yugioh')).toThrow();
});
});
describe('getGameBuylistConfig', () => {
it('returns defaults when the store has no stored config', () => {
expect(getGameBuylistConfig(fakeStore(), 'mtg').defaultRate).toBe(0.40);
});
it('deep-merges stored overrides over defaults', () => {
const store = fakeStore({ mtg: { enabled: true, stopGap: { rate: 0.55 } } });
const config = getGameBuylistConfig(store, 'mtg');
expect(config.enabled).toBe(true);
expect(config.stopGap.rate).toBe(0.55);
expect(config.stopGap.threshold).toBe(5.00); // untouched default survives
});
it('keeps games isolated β a pokemon override never leaks into mtg', () => {
const store = fakeStore({ pokemon: { defaultRate: 0.10 } });
expect(getGameBuylistConfig(store, 'mtg').defaultRate).toBe(0.40);
expect(getGameBuylistConfig(store, 'pokemon').defaultRate).toBe(0.10);
});
});
describe('applyGameBuylistConfigUpdate', () => {
it('writes the merged config under the game key and marks the path modified', () => {
const store = fakeStore();
const merged = applyGameBuylistConfigUpdate(store, 'mtg', { enabled: true });
expect(merged.enabled).toBe(true);
expect(store.buylistConfig.mtg.enabled).toBe(true);
expect(store.markModified).toHaveBeenCalledWith('buylistConfig');
});
it('updating one game leaves the other game untouched', () => {
const store = fakeStore({ mtg: { enabled: true } });
applyGameBuylistConfigUpdate(store, 'pokemon', { defaultRate: 0.2 });
expect(store.buylistConfig.mtg.enabled).toBe(true);
expect(store.buylistConfig.pokemon.defaultRate).toBe(0.2);
});
});
describe('buildBuylistConfigResponse', () => {
it('returns game, plugin rarity metadata sorted by sortOrder, and resolved config', () => {
const res = buildBuylistConfigResponse(fakeStore(), 'mtg');
expect(res.game).toBe('mtg');
// First MTG rarities by plugin sortOrder β from server/plugins/mtg/index.js
expect(res.rarities[0]).toEqual({ id: 'common', name: 'Common' });
expect(res.rarities[3]).toEqual({ id: 'mythic', name: 'Mythic Rare' });
expect(res.config.defaultRate).toBe(0.40);
});
});- [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/services/buylistConfigService.test.jsExpected: FAIL β Cannot find module './buylistConfigService.js'
- [ ] Step 3: Write the service
server/services/buylistConfigService.js:
javascript
/**
* Per-game buylist config resolution.
*
* Store.buylistConfig is Mixed, keyed by game id β same pattern (and same
* reasons) as Store.pricingConfig / pricingConfigService. This module builds
* each game's defaults and merges stored overrides over them. Every consumer
* (routes now; quote engine in phase 2) resolves through getGameBuylistConfig.
*/
/* global structuredClone */
'use strict';
const { getPlugin } = require('../plugins');
const { deepMerge } = require('./pricingConfigService');
// Condition keys match pricingConfig's conditionVariants vocabulary
// (server/schemas/pricingConfig.js VALID_CONDITIONS). Rates are fractions
// of the basis price (0.40 = pay 40%).
const BASE_BUYLIST_DEFAULTS = {
enabled: false,
priceBasis: 'market', // 'market' | 'lowest' | 'lower_of'
defaultRate: 0.40,
rarityRates: {}, // per-rarity overrides keyed by plugin rarity ids
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 },
storeCreditBonus: 0.25,
prioritization: {
outOfStockBoost: { enabled: false, bonus: 0.05 },
hotList: { enabled: false, bonus: 0.10 }
},
instantQuote: { web: true, kiosk: true }
};
function getDefaultGameBuylistConfig(gameId) {
getPlugin(gameId); // throws on unsupported game β the Β§5.5 guard
return structuredClone(BASE_BUYLIST_DEFAULTS);
}
function toPlain(c) {
return c && typeof c.toObject === 'function' ? c.toObject() : c;
}
function getGameBuylistConfig(store, gameId) {
const defaults = getDefaultGameBuylistConfig(gameId);
const bc = toPlain(store?.buylistConfig);
const stored = bc?.[gameId];
return stored ? deepMerge(defaults, toPlain(stored)) : defaults;
}
function applyGameBuylistConfigUpdate(store, gameId, update) {
const merged = deepMerge(getGameBuylistConfig(store, gameId), update);
const bc = toPlain(store.buylistConfig);
if (!bc || typeof bc !== 'object') {
store.buylistConfig = {};
}
store.buylistConfig[gameId] = merged;
store.markModified('buylistConfig');
return merged;
}
/**
* Full GET /api/buylist/config response: resolved config plus the rarity
* metadata the client needs to render per-rarity rate rows.
*/
function buildBuylistConfigResponse(store, gameId) {
const plugin = getPlugin(gameId);
return {
game: gameId,
rarities: Object.entries(plugin.rarities)
.sort(([, a], [, b]) => (a.sortOrder || 0) - (b.sortOrder || 0))
.map(([id, def]) => ({ id, name: def.name })),
config: getGameBuylistConfig(store, gameId)
};
}
module.exports = {
getDefaultGameBuylistConfig,
getGameBuylistConfig,
applyGameBuylistConfigUpdate,
buildBuylistConfigResponse
};- [ ] Step 4: Run the tests to verify they pass
bash
npx vitest run server/services/buylistConfigService.test.jsExpected: PASS (9 tests)
- [ ] Step 5: Commit
bash
git add server/services/buylistConfigService.js server/services/buylistConfigService.test.js
git commit -m "Add per-game buylist config resolution with plugin-driven defaults"Task 4: Zod schema for buylist config updates β
Files:
- Create:
server/schemas/buylistConfig.js - Modify:
server/schemas/index.js(add one spread to the barrel) - Test: append a describe block to
server/schemas/schemas.test.js
Interfaces:
Consumes: a game plugin instance (for
plugin.raritieskeys).Produces:
buildBuylistConfigUpdateSchema(plugin) β ZodObjectβ strict partial-update schema; unknown keys anywhere are validation errors. Consumed by Task 5's PUT route.[ ] Step 1: Write the failing tests
Append to server/schemas/schemas.test.js (it already imports from ./index.js β follow the file's existing import style at the top; add buildBuylistConfigUpdateSchema to that import):
javascript
describe('buildBuylistConfigUpdateSchema', () => {
// Real plugin: rarity keys must come from plugin vocabulary (Β§5.4)
let mtgPlugin;
beforeAll(async () => {
const { getPlugin } = await import('../plugins/index.js');
mtgPlugin = getPlugin('mtg');
});
it('accepts a valid partial update', () => {
const result = buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({
enabled: true,
priceBasis: 'lower_of',
defaultRate: 0.45,
rarityRates: { mythic: 0.5, rare: 0.45 },
stopGap: { threshold: 10, rate: 0.55 },
storeCreditBonus: 0.3,
instantQuote: { web: false }
});
expect(result.success).toBe(true);
});
it('rejects a rarity key outside the plugin vocabulary', () => {
const result = buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({
rarityRates: { legendary: 0.5 } // not an MTG rarity id
});
expect(result.success).toBe(false);
});
it('rejects rates outside 0..1', () => {
expect(buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({ defaultRate: 40 }).success).toBe(false);
expect(buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({ stopGap: { rate: -0.1 } }).success).toBe(false);
});
it('rejects unknown top-level keys (strict)', () => {
expect(buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({ surprise: true }).success).toBe(false);
});
it('rejects an invalid priceBasis', () => {
expect(buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({ priceBasis: 'vibes' }).success).toBe(false);
});
});- [ ] Step 2: Run to verify failure
bash
npx vitest run server/schemas/schemas.test.jsExpected: FAIL β buildBuylistConfigUpdateSchema is not exported
- [ ] Step 3: Write the schema and register it in the barrel
server/schemas/buylistConfig.js:
javascript
/**
* Buylist configuration schemas.
*
* Validates PUT /api/buylist/config bodies. Mirrors the shape produced by
* buylistConfigService; rarity keys are validated strictly against the game
* plugin's vocabulary (same approach as buildPricingConfigSchema).
*/
'use strict';
const { z } = require('zod');
// Shared with pricingConfig.js β condition vocabulary for all games
const VALID_CONDITIONS = ['nm', 'lp', 'mp', 'hp', 'damaged'];
// Rates are fractions of basis price: 0 = pay nothing, 1 = pay full price
const rateSchema = z.number().min(0, 'rate must be >= 0').max(1, 'rate must be <= 1');
function buildBuylistConfigUpdateSchema(plugin) {
const rarityKeys = Object.keys(plugin.rarities);
return z.object({
enabled: z.boolean().optional(),
priceBasis: z.enum(['market', 'lowest', 'lower_of']).optional(),
defaultRate: rateSchema.optional(),
rarityRates: z.object(Object.fromEntries(
rarityKeys.map((r) => [r, rateSchema.optional()])
)).strict().optional(),
bulk: z.object({
enabled: z.boolean().optional(),
threshold: z.number().min(0).optional(),
flatPrice: z.number().min(0).optional()
}).strict().optional(),
stopGap: z.object({
enabled: z.boolean().optional(),
threshold: z.number().min(0).optional(),
rate: rateSchema.optional()
}).strict().optional(),
conditionMultipliers: z.record(z.enum(VALID_CONDITIONS), rateSchema).optional(),
storeCreditBonus: rateSchema.optional(),
prioritization: z.object({
outOfStockBoost: z.object({ enabled: z.boolean().optional(), bonus: rateSchema.optional() }).strict().optional(),
hotList: z.object({ enabled: z.boolean().optional(), bonus: rateSchema.optional() }).strict().optional()
}).strict().optional(),
instantQuote: z.object({
web: z.boolean().optional(),
kiosk: z.boolean().optional()
}).strict().optional()
}).strict();
}
module.exports = { buildBuylistConfigUpdateSchema };In server/schemas/index.js, after the ...require('./sealedPricingConfig'), line, add:
javascript
// Buylist
...require('./buylistConfig'),- [ ] Step 4: Run to verify pass
bash
npx vitest run server/schemas/schemas.test.jsExpected: PASS (existing 44 + 5 new)
- [ ] Step 5: Commit
bash
git add server/schemas/buylistConfig.js server/schemas/index.js server/schemas/schemas.test.js
git commit -m "Validate buylist config updates against plugin rarity vocabularies"Task 5: GET/PUT /api/buylist/config routes β
Files:
- Modify:
server/routes/api.jsβ insert both routes directly after thePUT /pricing-confighandler's closing});(~line 1766)
Interfaces:
- Consumes:
buildBuylistConfigResponse/applyGameBuylistConfigUpdate(Task 3),buildBuylistConfigUpdateSchema(Task 4),req.store(set by the router's existing auth middleware β same as pricing-config). - Produces:
GET /api/buylist/config?game=<id>β{ game, rarities, config };PUT /api/buylist/config?game=<id>β{ success, game, config }. The client (Task 6) calls both.
Note on the quality bar's validate() requirement: like PUT /pricing-config, the schema here is built per-game at request time, so validation is inline safeParse with the same explanatory comment β this is the repo's accepted pattern for plugin-dependent schemas (see server/routes/api.js:1741-1746).
- [ ] Step 1: Add the routes
javascript
/**
* GET /api/buylist/config?game=<id>
* Per-game buylist configuration plus rarity metadata for the settings UI.
* game is REQUIRED β no default (CLAUDE.md Β§5.5; do not copy the legacy
* pricing-config fallback above).
*/
router.get('/buylist/config', async (req, res) => {
try {
const game = req.query.game;
const { isGameSupported } = require('../plugins');
if (!game || !isGameSupported(game)) {
return res.status(400).json({ error: `Missing or unsupported game: ${game}` });
}
const { buildBuylistConfigResponse } = require('../services/buylistConfigService');
res.json(buildBuylistConfigResponse(req.store, game));
} catch (error) {
logger.error('Failed to fetch buylist config', { error: error.message });
res.status(500).json({ error: 'Failed to fetch buylist config' });
}
});
/**
* PUT /api/buylist/config?game=<id>
* Partial-update the store's per-game buylist configuration.
* Schema depends on the game plugin, so validation is inline rather than via
* the static validate() middleware (same pattern as PUT /pricing-config).
*/
router.put('/buylist/config', async (req, res) => {
try {
const game = req.query.game;
const { isGameSupported, getPlugin } = require('../plugins');
if (!game || !isGameSupported(game)) {
return res.status(400).json({ error: `Missing or unsupported game: ${game}` });
}
const { buildBuylistConfigUpdateSchema } = require('../schemas');
const parsed = buildBuylistConfigUpdateSchema(getPlugin(game)).safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'Validation failed',
details: parsed.error.issues.map((i) => ({
path: i.path.join('.'),
message: i.message
}))
});
}
const { applyGameBuylistConfigUpdate } = require('../services/buylistConfigService');
const config = applyGameBuylistConfigUpdate(req.store, game, parsed.data);
await req.store.save();
logger.info('Updated buylist config for store', { shop: req.store.shop, game });
res.json({ success: true, game, config });
} catch (error) {
logger.error('Failed to update buylist config', { error: error.message });
res.status(500).json({ error: 'Failed to update buylist config' });
}
});- [ ] Step 2: Verify the server boots and the full suite stays green
bash
npm test 2>&1 | tail -5Expected: all passing (route handlers are exercised indirectly via schema + service tests; api.js has no route-level test file β matches existing repo pattern).
- [ ] Step 3: Manual smoke test (requires
docker compose up -d+npm run dev:no-worker)
bash
# In dev the standalone JWT path applies; fastest check is via the client in Task 6.
# Minimum server-side check β game guard behaves:
curl -s "http://localhost:3000/api/buylist/config" -H "Authorization: Bearer invalid" | head -c 200Expected: 400 (missing game) or 401 (auth) β NOT a 500, and NOT a response that assumed mtg.
- [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "Add per-game buylist config endpoints"Task 6: Buylist Settings page (client) β
Files:
- Create:
client/src/pages/buylist/BuylistPage.jsx - Test:
client/src/pages/buylist/BuylistPage.test.jsx
Interfaces:
Consumes:
apifromclient/src/utils/api.js(api.get('/buylist/config?game=...'),api.put('/buylist/config?game=...', body)); retroui barrel components.Produces: default-exported
BuylistPageReact component. Task 7 routes/buylistto it.[ ] Step 1: Write the failing test
client/src/pages/buylist/BuylistPage.test.jsx:
jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import BuylistPage from './BuylistPage';
vi.mock('../../utils/api', () => ({
api: { get: vi.fn(), put: vi.fn() }
}));
import { api } from '../../utils/api';
const configResponse = {
data: {
game: 'mtg',
rarities: [
{ id: 'common', name: 'Common' },
{ id: 'uncommon', name: 'Uncommon' },
{ id: 'rare', name: 'Rare' },
{ id: 'mythic', name: 'Mythic Rare' }
],
config: {
enabled: false,
priceBasis: 'market',
defaultRate: 0.4,
rarityRates: {},
bulk: { enabled: true, threshold: 0.25, flatPrice: 0.05 },
stopGap: { enabled: true, threshold: 5, rate: 0.5 },
conditionMultipliers: { nm: 1, lp: 0.85, mp: 0.7, hp: 0.5, damaged: 0.25 },
storeCreditBonus: 0.25,
prioritization: {
outOfStockBoost: { enabled: false, bonus: 0.05 },
hotList: { enabled: false, bonus: 0.1 }
},
instantQuote: { web: true, kiosk: true }
}
}
};
const gamesResponse = {
data: { games: [{ id: 'mtg', name: 'Magic: The Gathering' }, { id: 'pokemon', name: 'Pokemon TCG' }] }
};
beforeEach(() => {
vi.clearAllMocks();
api.get.mockImplementation((url) =>
url.startsWith('/games') ? Promise.resolve(gamesResponse) : Promise.resolve(configResponse)
);
api.put.mockResolvedValue({ data: { success: true, game: 'mtg', config: configResponse.data.config } });
});
describe('BuylistPage', () => {
it('loads and renders the mtg buylist config', async () => {
render(<BuylistPage />);
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/buylist/config?game=mtg'));
expect(await screen.findByLabelText(/default rate/i)).toHaveValue(40);
expect(screen.getByLabelText(/store credit bonus/i)).toHaveValue(25);
});
it('renders a rate input per plugin rarity', async () => {
render(<BuylistPage />);
expect(await screen.findByLabelText(/mythic rare rate/i)).toBeInTheDocument();
expect(screen.getByLabelText(/common rate/i)).toBeInTheDocument();
});
it('saves edited values as fractions via PUT with the game threaded explicitly', async () => {
render(<BuylistPage />);
const rateInput = await screen.findByLabelText(/default rate/i);
fireEvent.change(rateInput, { target: { value: '45' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(api.put).toHaveBeenCalledWith(
'/buylist/config?game=mtg',
expect.objectContaining({ defaultRate: 0.45 })
));
expect(await screen.findByText(/saved/i)).toBeInTheDocument();
});
});- [ ] Step 2: Run to verify failure
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistPage.test.jsxExpected: FAIL β Cannot find module './BuylistPage'
- [ ] Step 3: Write the page
client/src/pages/buylist/BuylistPage.jsx:
jsx
import { useCallback, useEffect, useState } from 'react';
import { api } from '../../utils/api';
import { Alert, Badge, Button, Card, Input, Switch, Text } from '../../components/retroui';
// Percent in the UI, fraction over the wire: the API stores 0.40, merchants read 40%.
const toPct = (fraction) => Math.round(fraction * 100);
const toFraction = (pct) => Number((Number(pct) / 100).toFixed(4));
function RateField({ id, label, value, onChange }) {
return (
<div className="flex items-center justify-between gap-3 py-2 border-b-2 border-muted last:border-b-0">
<label htmlFor={id} className="font-semibold text-sm">{label}</label>
<span className="flex items-center gap-1">
<Input
id={id}
aria-label={`${label} rate`}
type="number"
min={0}
max={100}
className="w-20 text-right"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<span className="text-sm font-bold">%</span>
</span>
</div>
);
}
export default function BuylistPage() {
const [games, setGames] = useState([]);
const [game, setGame] = useState('mtg');
const [rarities, setRarities] = useState([]);
const [form, setForm] = useState(null);
const [saving, setSaving] = useState(false);
const [savedMsg, setSavedMsg] = useState('');
const [error, setError] = useState('');
useEffect(() => {
api.get('/games')
.then(({ data }) => setGames(data.games || []))
.catch(() => setGames([]));
}, []);
const load = useCallback(async (gameId) => {
setError('');
setSavedMsg('');
try {
const { data } = await api.get(`/buylist/config?game=${gameId}`);
setRarities(data.rarities);
const c = data.config;
setForm({
enabled: c.enabled,
priceBasis: c.priceBasis,
defaultRate: toPct(c.defaultRate),
rarityRates: Object.fromEntries(
Object.entries(c.rarityRates || {}).map(([k, v]) => [k, toPct(v)])
),
stopGapThreshold: c.stopGap.threshold,
stopGapRate: toPct(c.stopGap.rate),
bulkThreshold: c.bulk.threshold,
bulkFlatPrice: c.bulk.flatPrice,
storeCreditBonus: toPct(c.storeCreditBonus),
instantQuoteWeb: c.instantQuote.web,
instantQuoteKiosk: c.instantQuote.kiosk
});
} catch {
setError('Failed to load buylist settings');
}
}, []);
useEffect(() => { load(game); }, [game, load]);
const save = async () => {
setSaving(true);
setError('');
setSavedMsg('');
try {
const body = {
enabled: form.enabled,
priceBasis: form.priceBasis,
defaultRate: toFraction(form.defaultRate),
rarityRates: Object.fromEntries(
Object.entries(form.rarityRates)
.filter(([, v]) => v !== '' && v !== undefined)
.map(([k, v]) => [k, toFraction(v)])
),
bulk: { threshold: Number(form.bulkThreshold), flatPrice: Number(form.bulkFlatPrice) },
stopGap: { threshold: Number(form.stopGapThreshold), rate: toFraction(form.stopGapRate) },
storeCreditBonus: toFraction(form.storeCreditBonus),
instantQuote: { web: form.instantQuoteWeb, kiosk: form.instantQuoteKiosk }
};
await api.put(`/buylist/config?game=${game}`, body);
setSavedMsg('Buylist settings saved');
} catch {
setError('Failed to save buylist settings');
} finally {
setSaving(false);
}
};
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
if (!form) {
return <div className="p-6"><Text>Loading buylist settingsβ¦</Text></div>;
}
return (
<div className="p-6 space-y-6 max-w-3xl">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<Text as="h1">Buylist</Text>
<p className="text-muted-foreground">
Rates and rules used to price cards customers sell to you. Order intake ships in a later release.
</p>
</div>
<Badge>Settings</Badge>
</div>
<div className="flex gap-2">
{games.map((g) => (
<Button
key={g.id}
variant={g.id === game ? 'default' : 'outline'}
size="sm"
onClick={() => setGame(g.id)}
>
{g.name}
</Button>
))}
</div>
{error && <Alert variant="destructive">{error}</Alert>}
{savedMsg && <Alert>{savedMsg}</Alert>}
<Card>
<Card.Header>
<Card.Title>General</Card.Title>
</Card.Header>
<Card.Content className="pt-0">
<div className="flex items-center justify-between gap-3 py-2">
<label htmlFor="buylist-enabled" className="font-semibold text-sm">Buylist enabled</label>
<Switch id="buylist-enabled" checked={form.enabled} onCheckedChange={set('enabled')} />
</div>
<div className="flex items-center justify-between gap-3 py-2">
<label htmlFor="price-basis" className="font-semibold text-sm">Price basis</label>
<select
id="price-basis"
className="border-2 border-black rounded px-2 py-1 font-semibold bg-card"
value={form.priceBasis}
onChange={(e) => set('priceBasis')(e.target.value)}
>
<option value="market">Market price</option>
<option value="lowest">Lowest listing</option>
<option value="lower_of">Lower of the two</option>
</select>
</div>
<RateField id="default-rate" label="Default rate" value={form.defaultRate} onChange={set('defaultRate')} />
<RateField id="credit-bonus" label="Store credit bonus" value={form.storeCreditBonus} onChange={set('storeCreditBonus')} />
</Card.Content>
</Card>
<Card>
<Card.Header>
<Card.Title>Rarity rates</Card.Title>
<Card.Description>Override the default rate per rarity. Blank = use default.</Card.Description>
</Card.Header>
<Card.Content className="pt-0">
{rarities.map((r) => (
<RateField
key={r.id}
id={`rarity-${r.id}`}
label={r.name}
value={form.rarityRates[r.id] ?? ''}
onChange={(v) => setForm((f) => ({ ...f, rarityRates: { ...f.rarityRates, [r.id]: v } }))}
/>
))}
</Card.Content>
</Card>
<Card>
<Card.Header>
<Card.Title>Overrides</Card.Title>
</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="bulk-threshold" className="font-semibold text-sm">
Bulk: cards under $
<Input id="bulk-threshold" aria-label="Bulk threshold" type="number" min={0} step="0.05"
className="w-20 mx-1 text-right inline-block"
value={form.bulkThreshold} onChange={(e) => set('bulkThreshold')(e.target.value)} />
pay flat $
<Input id="bulk-flat" aria-label="Bulk flat price" type="number" min={0} step="0.01"
className="w-20 mx-1 text-right inline-block"
value={form.bulkFlatPrice} onChange={(e) => set('bulkFlatPrice')(e.target.value)} />
</label>
</div>
<div className="flex items-center justify-between gap-3 py-2">
<label htmlFor="stopgap-threshold" className="font-semibold text-sm">
Stop-gap: cards over $
<Input id="stopgap-threshold" aria-label="Stop-gap threshold" type="number" min={0} step="0.5"
className="w-20 mx-1 text-right inline-block"
value={form.stopGapThreshold} onChange={(e) => set('stopGapThreshold')(e.target.value)} />
pay
<Input id="stopgap-rate" aria-label="Stop-gap rate" type="number" min={0} max={100}
className="w-20 mx-1 text-right inline-block"
value={form.stopGapRate} onChange={(e) => set('stopGapRate')(e.target.value)} />
%
</label>
</div>
</Card.Content>
</Card>
<Card>
<Card.Header>
<Card.Title>Instant quotes</Card.Title>
<Card.Description>Whether customers see a tentative offer immediately (always marked "subject to review").</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="iq-web" className="font-semibold text-sm">On your website</label>
<Switch id="iq-web" checked={form.instantQuoteWeb} onCheckedChange={set('instantQuoteWeb')} />
</div>
<div className="flex items-center justify-between gap-3 py-2">
<label htmlFor="iq-kiosk" className="font-semibold text-sm">On the in-store kiosk</label>
<Switch id="iq-kiosk" checked={form.instantQuoteKiosk} onCheckedChange={set('instantQuoteKiosk')} />
</div>
</Card.Content>
</Card>
<Button onClick={save} disabled={saving}>
{saving ? 'Savingβ¦' : 'Save settings'}
</Button>
</div>
);
}Adaptation note (not a placeholder β the code above is the target): if Switch's prop in client/src/components/retroui/Switch.jsx is onChange rather than Radix's onCheckedChange, match the actual component signature; check the barrel component before assuming.
- [ ] Step 4: Run to verify pass
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistPage.test.jsxExpected: PASS (3 tests). If a test fails on label lookup, fix the component's label/aria-label wiring β do not loosen the test.
- [ ] Step 5: Commit
bash
git add client/src/pages/buylist/
git commit -m "Add buylist settings page with per-game rates and instant-quote controls"Task 7: Wire the nav and route β
Files:
- Modify:
client/src/App.jsx(add/buylistroute beside the/queueroute, ~line 111; add the lazy/static import matching the file's existing import style) - Modify:
client/src/components/RadixShell.jsx:89(flip coming-soon to a real path) - Modify:
client/src/components/RadixShell.test.jsx:75-81(replace the coming-soon assertion)
Interfaces:
Consumes:
BuylistPage(Task 6).Produces:
/buylistnavigable from the Tools group in both embedded and standalone modes.[ ] Step 1: Update the failing test first
Replace the test at client/src/components/RadixShell.test.jsx:75-81:
jsx
it('navigates to /buylist when Buylist is clicked', async () => {
renderShell();
await waitFor(() => screen.getByText('Buylist'));
fireEvent.click(screen.getByText('Buylist'));
// Mirror this file's existing nav assertions (see the Dashboard/Queue
// click tests in this file) β assert against the same navigate mock they use.
expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/buylist'));
});If this file's other nav tests assert differently (e.g. via MemoryRouter location instead of a mockNavigate), use that file's existing mechanism β the assertion target is "clicking Buylist navigates to /buylist and does NOT open the Coming Soon dialog."
- [ ] Step 2: Run to verify the new test fails
bash
npx vitest run --config vitest.client.config.js client/src/components/RadixShell.test.jsxExpected: FAIL β clicking Buylist opens the Coming Soon dialog instead of navigating
- [ ] Step 3: Flip the nav item and add the route
client/src/components/RadixShell.jsx:89:
javascript
{ key: "buylist", label: "Buylist", path: "/buylist" },client/src/App.jsx β import BuylistPage alongside the other page imports, then next to the /queue route (~line 111):
jsx
<Route path="/buylist" element={wrap(<BuylistPage />)} />- [ ] Step 4: Run the full client suite and build
bash
npx vitest run --config vitest.client.config.js client/src/components/RadixShell.test.jsx
npm run test:client 2>&1 | tail -5
npm run build 2>&1 | tail -3Expected: PASS / PASS / build exit 0
- [ ] Step 5: Commit
bash
git add client/src/App.jsx client/src/components/RadixShell.jsx client/src/components/RadixShell.test.jsx
git commit -m "Open the Buylist nav item to the new settings page"Task 8: Quality bars and PR β
Files: none new
- [ ] Step 1: Run the full Β§7 checklist
bash
npm test # exit 0
npm run test:client # exit 0
npm run test:coverage # every modified file β₯70% on all four metrics
npm run lint # exit 0, zero new warnings
npm run build # exit 0 (client touched)
grep -rn "myshopify.com" server/models/BuylistOrder.js server/services/buylistConfigService.js server/schemas/buylistConfig.js # only test fixtures
git diff origin/main | grep -n "|| 'mtg'\|?? 'mtg'" # zero matches (Β§5.5)- [ ] Step 2: Manual end-to-end check in the dev store
bash
docker compose up -d
npm run dev:no-workerIn the browser (standalone mode): Tools β Buylist renders; switch MTG β Pokemon tabs (Pokemon must list its own 25+ rarities β proves plugin-driven vocabulary); edit Default rate to 45, Save, reload page β 45 persists (proves markModified round-trip through Mongo).
- [ ] Step 3: Open the PR
bash
git push -u origin claude/buylist-phase-1
gh pr create --title "Add Smart Buylist foundation: order model, per-game config, settings page" --body "$(cat <<'EOF'
Phase 1 of Smart Buylist (#318) per the approved spec
(docs/superpowers/specs/2026-07-14-smart-buylist-customer-intake-design.md).
- New `BuylistOrder` model (per-store, `buylist_orders`) with round-trip schema tests
- `Store.buylistConfig[gameId]` + `buylistConfigService` mirroring the pricingConfig pattern
- `GET/PUT /api/buylist/config?game=` β game REQUIRED, no `|| 'mtg'` fallback
- Buylist settings page under Tools β Buylist (was Coming Soon)
Game parity (Β§5.1): rarity vocabularies resolve from plugins, so **mtg**,
**pokemon**, and **riftbound** are all mirrored by construction (verified in the
UI: Pokemon renders its own rarity list). Riftbound gained inventory sync on
main (PRs #355β357) mid-implementation, so it is included in the buylist
settings UI rather than exempted, with no per-game branching.
Later phases (quote engine, public API/portal, kiosk auth, theme extension,
payouts) land as separate PRs per the spec.
π€ Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"Self-review (done at plan time) β
- Spec coverage (Phase 1 scope): model β (Task 1), buylistConfig + resolution β (Tasks 2β3), validation β (Task 4), endpoints β (Task 5), settings UI β (Task 6), nav β (Task 7), quality bars + parity statement β (Task 8). Out of scope by design: quote engine, public API, kiosk auth, portal, payouts (phases 2β6, separate plans).
- Type consistency:
getGameBuylistConfig/applyGameBuylistConfigUpdate/buildBuylistConfigResponsenames match across Tasks 3, 5;buildBuylistConfigUpdateSchemamatches across Tasks 4, 5; rates are fractions (0β1) on the wire and in storage, percent only inside the page component. - Known adaptation points (flagged inline, with the target behavior stated): retroui
Switchprop name (Task 6), RadixShell test's navigation mock mechanism (Task 7), App.jsx import style (Task 7).
