Appearance
Game-Specific Pricing & Rarity Rules — 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: Split Store.pricingConfig per game (mtg/pokemon/any registered plugin) so each game's sync page has an independent pricing config whose minimum-price rules key off that game's actual rarities and finishes.
Architecture: A new pricingConfigService resolves per-game configs (plugin defaults merged with stored overrides, with a legacy flat-shape fallback for un-migrated stores). The pricing pipeline's minimum-price step becomes a generic byRarity[rarity][finishKey] lookup, and both raw-price lookups stop collapsing finish keys to a binary normal/foil. All consumers (routes/api.js, syncService, priceUpdateService/processor) resolve config through the service. The client pricing UI is extracted from Home.jsx into a standalone game-aware PricingConfigPanel component (PR #306's nav restructure will later mount this same component at /catalog/:game/settings/pricing — do not build deeper into Home.jsx).
Tech Stack: Node/Express (CommonJS), Mongoose, Zod, Vitest (tests use ESM import), React 18 + retroui/Tailwind.
Spec: docs/superpowers/specs/2026-07-11-game-specific-pricing-rarity-design.md
Global Constraints
- Server code is CommonJS (
require/module.exports); test files use ESMimport. - Tests co-located as
filename.test.js; run withnpm test(server),npm run test:client(client). 70% coverage on modified files. - Route handlers stay thin; testable logic lives in services (repo has no supertest — route tests are unit tests on the service functions).
- New/changed endpoints validate with Zod via
server/schemas/. Store.pricingConfigbecomesmongoose.Schema.Types.Mixed— every write MUST callstore.markModified('pricingConfig').- Rarity keys are lowercase everywhere (Pokemon keys contain spaces, e.g.
'rare holo'). Finish keys are the plugin's price-bucket ids: MTGnormal|foil|etched, Pokemonnormal|holofoil|reverseHolofoil|1stEditionNormal|1stEditionHolofoil. - Client API calls go through
client/src/utils/api.js(axios-style:api.get(path, { params })). - Commit after every task; pre-commit hook runs ESLint.
Task 1: Plugin metadata — priceSources + full-finish default minimums
Files:
- Modify:
server/plugins/BaseGamePlugin.js(addpriceSourcesgetter near the existingpriceSourcegetter, ~line 63) - Modify:
server/plugins/mtg/index.js(getDefaultMinimumPrices()~line 291; addpriceSourcesoverride) - Modify:
server/plugins/pokemon/index.js(getDefaultMinimumPrices()~line 183) - Create:
server/plugins/pricingDefaults.test.js
Interfaces:
Produces:
plugin.priceSources→string[](allowed pricing sources, first = default priority). Base returns['tcgplayer']; MTG overrides with['tcgplayer', 'cardkingdom'].Produces:
plugin.getDefaultMinimumPrices()→{ [rarityId]: { [finishId]: number } }covering every key inplugin.finishes.[ ] Step 1: Write the failing test
js
// server/plugins/pricingDefaults.test.js
import { describe, it, expect } from 'vitest';
import { getAllPlugins } from './index.js';
describe('plugin pricing metadata', () => {
const plugins = Object.values(getAllPlugins());
it('every plugin declares non-empty priceSources from the known set', () => {
for (const plugin of plugins) {
expect(plugin.priceSources.length).toBeGreaterThan(0);
for (const s of plugin.priceSources) {
expect(['tcgplayer', 'cardkingdom']).toContain(s);
}
}
});
it('mtg allows cardkingdom, other games are tcgplayer-only', () => {
for (const plugin of plugins) {
if (plugin.gameId === 'mtg') {
expect(plugin.priceSources).toEqual(['tcgplayer', 'cardkingdom']);
} else {
expect(plugin.priceSources).toEqual(['tcgplayer']);
}
}
});
it('default minimum prices cover every declared finish for every listed rarity', () => {
for (const plugin of plugins) {
const defaults = plugin.getDefaultMinimumPrices();
const finishKeys = Object.keys(plugin.finishes);
for (const [rarity, mins] of Object.entries(defaults)) {
for (const finish of finishKeys) {
expect(mins[finish], `${plugin.gameId} ${rarity} ${finish}`).toBeTypeOf('number');
}
}
}
});
it('default minimum rarity keys are a subset of declared rarities', () => {
for (const plugin of plugins) {
const rarityKeys = Object.keys(plugin.rarities);
for (const rarity of Object.keys(plugin.getDefaultMinimumPrices())) {
expect(rarityKeys, `${plugin.gameId} ${rarity}`).toContain(rarity);
}
}
});
});- [ ] Step 2: Run test to verify it fails
Run: npx vitest run server/plugins/pricingDefaults.test.js Expected: FAIL — plugin.priceSources is undefined; MTG defaults lack etched; Pokemon defaults lack 1stEditionNormal/1stEditionHolofoil.
- [ ] Step 3: Implement
In server/plugins/BaseGamePlugin.js, after the priceSource getter:
js
/**
* Pricing sources this game's price documents actually carry.
* First entry is the default priority. Only MTG has CardKingdom data.
* @returns {string[]}
*/
get priceSources() {
return ['tcgplayer'];
}In server/plugins/mtg/index.js, inside the class (near the other getters):
js
get priceSources() {
return ['tcgplayer', 'cardkingdom'];
}Replace MTG getDefaultMinimumPrices() (etched mirrors foil — preserves today's effective behavior where etched cards received the foil minimum):
js
getDefaultMinimumPrices() {
return {
common: { normal: 0.25, foil: 0.50, etched: 0.50 },
uncommon: { normal: 0.25, foil: 0.50, etched: 0.50 },
rare: { normal: 1.29, foil: 1.29, etched: 1.29 },
mythic: { normal: 0.99, foil: 0.99, etched: 0.99 },
special: { normal: 0.25, foil: 0.50, etched: 0.50 },
bonus: { normal: 0.25, foil: 0.50, etched: 0.50 }
};
}Replace Pokemon getDefaultMinimumPrices() — keep every existing rarity row, add the two 1st-edition finish keys to each (1stEditionNormal mirrors normal, 1stEditionHolofoil mirrors holofoil). Example rows (apply the same pattern to all existing rows):
js
getDefaultMinimumPrices() {
return {
common: { normal: 0.25, holofoil: 0.50, reverseHolofoil: 0.50, '1stEditionNormal': 0.25, '1stEditionHolofoil': 0.50 },
uncommon: { normal: 0.25, holofoil: 0.50, reverseHolofoil: 0.50, '1stEditionNormal': 0.25, '1stEditionHolofoil': 0.50 },
rare: { normal: 0.50, holofoil: 1.00, reverseHolofoil: 0.75, '1stEditionNormal': 0.50, '1stEditionHolofoil': 1.00 },
'rare holo': { normal: 1.00, holofoil: 1.00, reverseHolofoil: 1.00, '1stEditionNormal': 1.00, '1stEditionHolofoil': 1.00 },
'rare holo v': { normal: 1.50, holofoil: 1.50, reverseHolofoil: 1.50, '1stEditionNormal': 1.50, '1stEditionHolofoil': 1.50 },
'rare holo vmax': { normal: 2.00, holofoil: 2.00, reverseHolofoil: 2.00, '1stEditionNormal': 2.00, '1stEditionHolofoil': 2.00 },
'rare ultra': { normal: 3.00, holofoil: 3.00, reverseHolofoil: 3.00, '1stEditionNormal': 3.00, '1stEditionHolofoil': 3.00 },
'rare secret': { normal: 5.00, holofoil: 5.00, reverseHolofoil: 5.00, '1stEditionNormal': 5.00, '1stEditionHolofoil': 5.00 },
'illustration rare': { normal: 3.00, holofoil: 3.00, reverseHolofoil: 3.00, '1stEditionNormal': 3.00, '1stEditionHolofoil': 3.00 },
'special illustration rare': { normal: 10.00, holofoil: 10.00, reverseHolofoil: 10.00, '1stEditionNormal': 10.00, '1stEditionHolofoil': 10.00 },
'hyper rare': { normal: 15.00, holofoil: 15.00, reverseHolofoil: 15.00, '1stEditionNormal': 15.00, '1stEditionHolofoil': 15.00 }
};
}Note the subset test: every rarity key above must exist in plugin.rarities. If any doesn't (e.g. Pokemon 'illustration rare'), add it to the rarities getter with the next sortOrder rather than deleting the default row. Riftbound inherits the base getDefaultMinimumPrices() ({}) — the coverage test iterates Object.entries(defaults) so an empty map passes.
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run server/plugins/pricingDefaults.test.js Expected: PASS (fix any rarity-key mismatch the subset test surfaces).
- [ ] Step 5: Run existing plugin tests, then commit
Run: npx vitest run server/plugins/ Expected: PASS (riftbound test untouched).
bash
git add server/plugins/
git commit -m "feat(pricing): plugin priceSources + full-finish default minimums"Task 2: Store model — pricingConfig becomes per-game Mixed
Files:
- Modify:
server/models/Store.js(replace thepricingConfigsubdocument, lines ~101-239; removeminimumPricesfromgameConfig.mtgandgameConfig.pokemon, lines ~77-98) - Create:
server/models/Store.test.js
Interfaces:
Produces:
Store.pricingConfigismongoose.Schema.Types.Mixed, default{}. Shape by convention:{ [gameId]: <game pricing config> }(legacy docs still hold the old flat shape until migrated — readers handle both via Task 3's service).gameConfig.<game>.enabledand.selectedSetsare unchanged (still used byroutes/api.js:1627-1668).[ ] Step 1: Write the failing test
js
// server/models/Store.test.js
import { describe, it, expect } from 'vitest';
import Store from './Store.js';
describe('Store schema — per-game pricingConfig', () => {
const base = { shop: 't.myshopify.com', accessToken: 'x', scope: 'read' };
it('defaults pricingConfig to an empty object', () => {
const store = new Store(base);
expect(store.pricingConfig).toEqual({});
});
it('accepts per-game keys without stripping them', () => {
const store = new Store({
...base,
pricingConfig: {
mtg: { globalModifier: { enabled: true, value: 1.2, threshold: 49.99 } },
pokemon: { minimumPrices: { byRarity: { 'rare holo': { holofoil: 2.5 } } } }
}
});
expect(store.pricingConfig.mtg.globalModifier.value).toBe(1.2);
expect(store.pricingConfig.pokemon.minimumPrices.byRarity['rare holo'].holofoil).toBe(2.5);
});
it('no longer defines gameConfig minimumPrices defaults', () => {
const store = new Store(base);
expect(store.gameConfig?.mtg?.minimumPrices).toBeUndefined();
expect(store.gameConfig?.pokemon?.minimumPrices).toBeUndefined();
// selectedSets survives the cleanup
expect(store.gameConfig?.mtg?.selectedSets).toEqual([]);
});
});- [ ] Step 2: Run test to verify it fails
Run: npx vitest run server/models/Store.test.js Expected: FAIL — typed subdocument strips mtg/pokemon keys and fills old defaults; gameConfig.*.minimumPrices defaults exist.
- [ ] Step 3: Implement
In server/models/Store.js:
- Delete the entire
minimumPricesblock fromgameConfig.mtg(lines ~77-82) andgameConfig.pokemon(lines ~89-98), keepingenabledandselectedSetsin both. - Replace the whole
pricingConfig: { ... }subdocument (lines ~101-239) with:
js
// Per-game pricing configuration, keyed by game id (mtg, pokemon, ...).
// Mixed by design: rarity/finish vocabularies vary per game and are owned
// by server/plugins/*. Resolve via pricingConfigService.getGamePricingConfig
// (which also reads the pre-migration flat shape); ALWAYS call
// markModified('pricingConfig') after writing.
pricingConfig: {
type: mongoose.Schema.Types.Mixed,
default: {}
},- [ ] Step 4: Run test to verify it passes
Run: npx vitest run server/models/Store.test.js Expected: PASS
- [ ] Step 5: Commit
bash
git add server/models/Store.js server/models/Store.test.js
git commit -m "feat(pricing): Store.pricingConfig becomes per-game Mixed, drop dead gameConfig minimums"Task 3: pricingConfigService — per-game config resolution
Files:
- Create:
server/services/pricingConfigService.js - Create:
server/services/pricingConfigService.test.js
Interfaces:
Consumes:
getPlugin(gameId)fromserver/plugins(plugin.priceSources,plugin.getDefaultMinimumPrices()from Task 1).Produces (all exported):
getDefaultGamePricingConfig(gameId)→ full config object for the game.isLegacyFlatConfig(pc)→boolean(true whenpcis the pre-split flat shape).convertLegacyConfig(flat)→ partial config in the new shape (oldminimumPrices.rare/mythic→byRarity; numericglobalModifier→ object).getGamePricingConfig(store, gameId)→ resolved full config (defaults ← stored overrides; flat fallback applies tomtgonly).applyGamePricingConfigUpdate(store, gameId, update)→ mergesupdateinto the store's config for that game, writesstore.pricingConfig[gameId], callsmarkModified, returns the merged config. Does NOT save.
[ ] Step 1: Write the failing test
js
// server/services/pricingConfigService.test.js
import { describe, it, expect } from 'vitest';
import {
getDefaultGamePricingConfig,
isLegacyFlatConfig,
convertLegacyConfig,
getGamePricingConfig,
applyGamePricingConfigUpdate
} from './pricingConfigService.js';
const LEGACY_FLAT = {
useRawPlatformPricing: false,
sourcePriority: ['cardkingdom', 'tcgplayer'],
globalModifier: { enabled: true, value: 1.25, threshold: 30 },
rounding: { enabled: false, decimals: [99] },
minimumPrices: {
enabled: true,
global: 0.75,
rare: { normal: 2.00, foil: 2.50 },
mythic: { normal: 1.50, foil: 1.75 }
}
};
function fakeStore(pricingConfig) {
return { pricingConfig, markModified: () => {} };
}
describe('getDefaultGamePricingConfig', () => {
it('builds mtg defaults from the plugin', () => {
const cfg = getDefaultGamePricingConfig('mtg');
expect(cfg.sourcePriority).toEqual(['tcgplayer', 'cardkingdom']);
expect(cfg.globalModifier).toEqual({ enabled: true, value: 1.1, threshold: 49.99 });
expect(cfg.minimumPrices.byRarity.rare).toEqual({ normal: 1.29, foil: 1.29, etched: 1.29 });
expect(cfg.minimumPrices.global).toBe(0.59);
});
it('builds pokemon defaults with pokemon rarities and tcgplayer-only sources', () => {
const cfg = getDefaultGamePricingConfig('pokemon');
expect(cfg.sourcePriority).toEqual(['tcgplayer']);
expect(cfg.minimumPrices.byRarity['rare holo'].holofoil).toBe(1.00);
});
});
describe('legacy flat shape handling', () => {
it('detects flat configs and rejects per-game/empty ones', () => {
expect(isLegacyFlatConfig(LEGACY_FLAT)).toBe(true);
expect(isLegacyFlatConfig({})).toBe(false);
expect(isLegacyFlatConfig(undefined)).toBe(false);
expect(isLegacyFlatConfig({ mtg: { rounding: { enabled: true } } })).toBe(false);
});
it('converts rare/mythic minimums to byRarity', () => {
const converted = convertLegacyConfig(LEGACY_FLAT);
expect(converted.minimumPrices.byRarity.rare).toEqual({ normal: 2.00, foil: 2.50 });
expect(converted.minimumPrices.byRarity.mythic).toEqual({ normal: 1.50, foil: 1.75 });
expect(converted.minimumPrices.rare).toBeUndefined();
expect(converted.minimumPrices.global).toBe(0.75);
});
it('converts pre-nested numeric globalModifier', () => {
const converted = convertLegacyConfig({ globalModifier: 1.3, modifierThreshold: 25 });
expect(converted.globalModifier).toEqual({ enabled: true, value: 1.3, threshold: 25 });
});
});
describe('getGamePricingConfig', () => {
it('returns pure defaults when the store has nothing', () => {
const cfg = getGamePricingConfig(fakeStore({}), 'mtg');
expect(cfg).toEqual(getDefaultGamePricingConfig('mtg'));
});
it('treats a legacy flat config as the mtg config', () => {
const cfg = getGamePricingConfig(fakeStore(LEGACY_FLAT), 'mtg');
expect(cfg.globalModifier.value).toBe(1.25);
expect(cfg.minimumPrices.byRarity.rare.normal).toBe(2.00);
// etched not in legacy shape -> filled from plugin defaults
expect(cfg.minimumPrices.byRarity.rare.etched).toBe(1.29);
});
it('does NOT leak the legacy flat config into pokemon', () => {
const cfg = getGamePricingConfig(fakeStore(LEGACY_FLAT), 'pokemon');
expect(cfg).toEqual(getDefaultGamePricingConfig('pokemon'));
});
it('per-game overrides win over defaults and stay isolated per game', () => {
const store = fakeStore({
mtg: { globalModifier: { value: 2.0 } },
pokemon: { minimumPrices: { byRarity: { common: { holofoil: 9.99 } } } }
});
const mtg = getGamePricingConfig(store, 'mtg');
const pokemon = getGamePricingConfig(store, 'pokemon');
expect(mtg.globalModifier.value).toBe(2.0);
expect(mtg.globalModifier.threshold).toBe(49.99); // default preserved
expect(pokemon.globalModifier.value).toBe(1.1); // isolation
expect(pokemon.minimumPrices.byRarity.common.holofoil).toBe(9.99);
expect(pokemon.minimumPrices.byRarity.common.normal).toBe(0.25); // default preserved
});
});
describe('applyGamePricingConfigUpdate', () => {
it('writes the merged config under the game key and marks modified', () => {
let marked = null;
const store = { pricingConfig: {}, markModified: (p) => { marked = p; } };
const merged = applyGamePricingConfigUpdate(store, 'pokemon', {
globalModifier: { value: 1.5 }
});
expect(store.pricingConfig.pokemon.globalModifier.value).toBe(1.5);
expect(merged.minimumPrices.byRarity.common.normal).toBe(0.25);
expect(marked).toBe('pricingConfig');
});
it('folds a legacy flat config into mtg before writing another game', () => {
const store = { pricingConfig: { ...LEGACY_FLAT }, markModified: () => {} };
applyGamePricingConfigUpdate(store, 'pokemon', { rounding: { enabled: false } });
expect(store.pricingConfig.mtg.globalModifier.value).toBe(1.25); // preserved
expect(store.pricingConfig.pokemon.rounding.enabled).toBe(false);
expect(store.pricingConfig.globalModifier).toBeUndefined(); // flat keys gone
});
});- [ ] Step 2: Run test to verify it fails
Run: npx vitest run server/services/pricingConfigService.test.js Expected: FAIL — module doesn't exist.
- [ ] Step 3: Implement
js
// server/services/pricingConfigService.js
/**
* Per-game pricing config resolution.
*
* Store.pricingConfig is Mixed, keyed by game id. This module is the single
* place that (a) builds each game's defaults from its plugin, (b) reads the
* pre-migration flat shape as the store's MTG config, and (c) merges stored
* overrides over defaults. Every consumer (routes, syncService,
* priceUpdateProcessor) resolves config through getGamePricingConfig.
*/
'use strict';
const { getPlugin } = require('../plugins');
const BASE_PIPELINE_DEFAULTS = {
useRawPlatformPricing: false,
preSalePlaceholderPrice: 999.99,
globalModifier: { enabled: true, value: 1.1, threshold: 49.99 },
conditionVariants: {
enabled: false,
conditionMultipliers: { nm: 1.0, lp: 0.95, mp: 0.85, hp: 0.70, damaged: 0.50 }
},
rounding: { enabled: true, decimals: [39, 59, 79, 99] },
minimumPrices: { enabled: true, global: 0.59 }
};
// Plain-object deep merge; arrays and scalars replace, objects recurse.
function deepMerge(base, override) {
if (override === undefined) return base;
if (base === undefined) return override;
if (
typeof base !== 'object' || base === null || Array.isArray(base) ||
typeof override !== 'object' || override === null || Array.isArray(override)
) {
return override;
}
const out = { ...base };
for (const key of Object.keys(override)) {
out[key] = deepMerge(base[key], override[key]);
}
return out;
}
function getDefaultGamePricingConfig(gameId) {
const plugin = getPlugin(gameId);
return deepMerge(BASE_PIPELINE_DEFAULTS, {
sourcePriority: [...plugin.priceSources],
minimumPrices: { byRarity: plugin.getDefaultMinimumPrices() }
});
}
const FLAT_SHAPE_KEYS = [
'useRawPlatformPricing', 'sourcePriority', 'preSalePlaceholderPrice',
'globalModifier', 'conditionVariants', 'rounding', 'minimumPrices',
'modifierThreshold'
];
function isLegacyFlatConfig(pc) {
if (!pc || typeof pc !== 'object') return false;
return FLAT_SHAPE_KEYS.some((key) => key in pc);
}
/**
* Convert the pre-split flat config into the new per-game shape.
* Handles both quirky legacy forms the old PUT handler tolerated:
* numeric globalModifier (+ top-level modifierThreshold) and the
* rare/mythic minimum structure.
*/
function convertLegacyConfig(flat) {
const out = {};
if (flat.useRawPlatformPricing !== undefined) out.useRawPlatformPricing = flat.useRawPlatformPricing;
if (flat.sourcePriority !== undefined) out.sourcePriority = flat.sourcePriority;
if (flat.preSalePlaceholderPrice !== undefined) out.preSalePlaceholderPrice = flat.preSalePlaceholderPrice;
if (typeof flat.globalModifier === 'number') {
out.globalModifier = {
enabled: true,
value: flat.globalModifier,
threshold: flat.modifierThreshold !== undefined ? flat.modifierThreshold : 49.99
};
} else if (flat.globalModifier !== undefined) {
out.globalModifier = flat.globalModifier;
}
if (flat.conditionVariants !== undefined) out.conditionVariants = flat.conditionVariants;
if (flat.rounding !== undefined) out.rounding = flat.rounding;
if (flat.minimumPrices !== undefined) {
const { rare, mythic, ...rest } = flat.minimumPrices;
const byRarity = {};
if (rare) byRarity.rare = rare;
if (mythic) byRarity.mythic = mythic;
out.minimumPrices = { ...rest };
if (Object.keys(byRarity).length > 0) out.minimumPrices.byRarity = byRarity;
}
return out;
}
function toPlain(pc) {
return pc && typeof pc.toObject === 'function' ? pc.toObject() : pc;
}
function getGamePricingConfig(store, gameId) {
const defaults = getDefaultGamePricingConfig(gameId);
const pc = toPlain(store?.pricingConfig);
let stored = pc?.[gameId];
if (!stored && gameId === 'mtg' && isLegacyFlatConfig(pc)) {
stored = convertLegacyConfig(pc);
}
return stored ? deepMerge(defaults, toPlain(stored)) : defaults;
}
function applyGamePricingConfigUpdate(store, gameId, update) {
const merged = deepMerge(getGamePricingConfig(store, gameId), update);
const pc = toPlain(store.pricingConfig);
if (!pc || typeof pc !== 'object' || isLegacyFlatConfig(pc)) {
// Replace the container; preserve the legacy data by folding it into mtg
// first so writing pokemon doesn't destroy un-migrated MTG settings.
const next = {};
if (isLegacyFlatConfig(pc)) {
next.mtg = getGamePricingConfig(store, 'mtg');
}
store.pricingConfig = next;
}
store.pricingConfig[gameId] = merged;
store.markModified('pricingConfig');
return merged;
}
module.exports = {
getDefaultGamePricingConfig,
isLegacyFlatConfig,
convertLegacyConfig,
getGamePricingConfig,
applyGamePricingConfigUpdate,
// exported for reuse in tests / other services
deepMerge
};- [ ] Step 4: Run test to verify it passes
Run: npx vitest run server/services/pricingConfigService.test.js Expected: PASS
- [ ] Step 5: Commit
bash
git add server/services/pricingConfigService.js server/services/pricingConfigService.test.js
git commit -m "feat(pricing): pricingConfigService resolves per-game config with legacy fallback"Task 4: Pipeline — generic byRarity minimums + finish passthrough
Files:
- Modify:
server/services/priceLookupService.js(DEFAULT_CONFIG~line 221;applyMinimumPriceRules~line 342;getRawPriceForFinish~line 559;getRawPokemonPriceForFinish~line 898) - Modify:
server/services/priceLookupService.test.js(update old-shape tests; add new ones)
Interfaces:
Consumes: nothing new (config shape from Task 3 by convention).
Produces:
applyMinimumPriceRules(price, finishKey, rarity, config)— second param is now the price-bucket finish key (normal|foil|etchedfor MTG,normal|holofoil|reverseHolofoil|1stEditionNormal|1stEditionHolofoilfor Pokemon).getRawPriceForFinish(...)/getRawPokemonPriceForFinish(...)return{ rawPrice, priceType }wherepriceTypeis now that same bucket key (previously collapsed tonormal|foil).calculatePriceWithCondition(priceRaw, finishKey, rarity, condition, config)threads it through unchanged.[ ] Step 1: Write the failing tests
Add to server/services/priceLookupService.test.js (imports at top of file already pull from ./priceLookupService.js — extend them with the needed names):
js
describe('applyMinimumPriceRules (byRarity)', () => {
const config = {
minimumPrices: {
enabled: true,
global: 0.59,
byRarity: {
rare: { normal: 1.29, foil: 1.49, etched: 1.99 },
'rare holo': { holofoil: 2.00, reverseHolofoil: 1.50 }
}
}
};
it('applies the rarity+finish floor', () => {
expect(applyMinimumPriceRules(0.10, 'foil', 'rare', config)).toBe(1.49);
expect(applyMinimumPriceRules(0.10, 'etched', 'rare', config)).toBe(1.99);
});
it('gives pokemon finishes independent floors', () => {
expect(applyMinimumPriceRules(0.10, 'holofoil', 'rare holo', config)).toBe(2.00);
expect(applyMinimumPriceRules(0.10, 'reverseHolofoil', 'rare holo', config)).toBe(1.50);
});
it('falls back to the global minimum for unknown rarity or finish', () => {
expect(applyMinimumPriceRules(0.10, 'normal', 'common', config)).toBe(0.59);
expect(applyMinimumPriceRules(0.10, 'holofoil', 'rare', config)).toBe(0.59);
});
it('never lowers a price above the floors', () => {
expect(applyMinimumPriceRules(5.00, 'foil', 'rare', config)).toBe(5.00);
});
it('respects the master toggle and enabled flag', () => {
expect(applyMinimumPriceRules(0.10, 'foil', 'rare', { ...config, useRawPlatformPricing: true })).toBe(0.10);
expect(applyMinimumPriceRules(0.10, 'foil', 'rare', { minimumPrices: { ...config.minimumPrices, enabled: false } })).toBe(0.10);
});
});
describe('finish passthrough', () => {
it('MTG etched keeps its own bucket', () => {
const priceData = { paper: { tcgplayer: { retail: { etched: 3.33 } } } };
const { rawPrice, priceType } = getRawPriceForFinish(priceData, 'Etched');
expect(rawPrice).toBe(3.33);
expect(priceType).toBe('etched');
});
it('MTG display foils still collapse to the foil bucket', () => {
const priceData = { paper: { tcgplayer: { retail: { foil: 2.22 } } } };
expect(getRawPriceForFinish(priceData, 'Surge Foil').priceType).toBe('foil');
});
it('Pokemon reverse holofoil keeps its own bucket', () => {
const priceData = { paper: { tcgplayer: { retail: { reverseHolofoil: 1.11 } } } };
const { rawPrice, priceType } = getRawPokemonPriceForFinish(priceData, 'reverseHolofoil');
expect(rawPrice).toBe(1.11);
expect(priceType).toBe('reverseHolofoil');
});
});- [ ] Step 2: Run to verify the new tests fail
Run: npx vitest run server/services/priceLookupService.test.js Expected: new tests FAIL (byRarity ignored; priceType comes back 'foil').
- [ ] Step 3: Implement
In server/services/priceLookupService.js:
DEFAULT_CONFIG.minimumPrices(~line 250) becomes:
js
// Step 4: Minimum prices (byRarity keyed on rarity -> finish bucket).
// Real configs come from pricingConfigService; this is the last-resort
// fallback when callers pass no config at all.
minimumPrices: {
enabled: true,
global: 0.59,
byRarity: {
rare: { normal: 1.29, foil: 1.29, etched: 1.29 },
mythic: { normal: 0.99, foil: 0.99, etched: 0.99 }
}
}- Replace
applyMinimumPriceRules(~line 342):
js
/**
* Apply minimum price rules based on rarity + finish bucket
* Step 4 of pricing pipeline (final step after rounding)
* @param {number} price - Price after modifier and rounding
* @param {string} finishKey - Price-bucket finish key (normal, foil, etched,
* holofoil, reverseHolofoil, 1stEditionNormal, 1stEditionHolofoil)
* @param {string} rarity - Card rarity (lowercase, game-specific vocabulary)
* @param {object} config - Pricing configuration
* @returns {number} Price with minimum rules applied
*/
function applyMinimumPriceRules(price, finishKey, rarity, config = DEFAULT_CONFIG) {
if (config.useRawPlatformPricing) {
return price;
}
const minPrices = config.minimumPrices || DEFAULT_CONFIG.minimumPrices;
if (minPrices.enabled === false) {
return price;
}
let finalPrice = price;
const floor = minPrices.byRarity?.[rarity]?.[finishKey];
if (floor != null) {
finalPrice = Math.max(finalPrice, floor);
}
if (minPrices.global !== undefined) {
finalPrice = Math.max(finalPrice, minPrices.global);
}
return finalPrice;
}- In
getRawPriceForFinish(~line 564-565), replace:
js
const priceKey = FINISH_MAP[finishType] || 'normal';
const priceType = priceKey === 'normal' ? 'normal' : 'foil';with:
js
const priceKey = FINISH_MAP[finishType] || 'normal';
// priceType IS the price bucket now (normal/foil/etched) so per-finish
// minimums can distinguish them downstream.
const priceType = priceKey;- In
getRawPokemonPriceForFinish(~line 904-905), replace:
js
const priceKey = POKEMON_FINISH_MAP[normalizedFinish] || 'normal';
const priceType = priceKey === 'normal' ? 'normal' : 'foil';with:
js
const priceKey = POKEMON_FINISH_MAP[normalizedFinish] || 'normal';
const priceType = priceKey; // full bucket key for per-finish minimums- Update any existing tests in
priceLookupService.test.jsthat build the oldminimumPrices: { rare: {...}, mythic: {...} }shape or assertpriceType === 'foil'for etched/holofoil inputs — convert them to thebyRarityshape / bucket expectations. Do not delete assertions; port them.
- [ ] Step 4: Run the full service test file
Run: npx vitest run server/services/priceLookupService.test.js Expected: PASS (all ported + new tests).
- [ ] Step 5: Run the whole server suite to catch shape fallout early
Run: npm test Expected: failures only in files later tasks own (syncService.test.js, priceUpdateProcessor.test.js, schemas.test.js if they pin the old shape). If a failure is in THIS file's scope, fix it now; note others for their tasks — do not fix them here.
- [ ] Step 6: Commit
bash
git add server/services/priceLookupService.js server/services/priceLookupService.test.js
git commit -m "feat(pricing): generic byRarity minimums + real finish buckets through the pipeline"Task 5: Zod schema factory — buildPricingConfigSchema(plugin)
Files:
- Modify:
server/schemas/pricingConfig.js - Modify:
server/schemas/index.js(export the factory) - Modify:
server/schemas/schemas.test.js(new describe block)
Interfaces:
Consumes:
plugin.rarities,plugin.finishes,plugin.priceSources(Task 1).Produces:
buildPricingConfigSchema(plugin)→ Zod object schema for a partial per-game config update. Rejects rarity keys not inplugin.rarities, finish keys not inplugin.finishes, and sources not inplugin.priceSources. The old flatpricingConfigUpdateSchemastays exported for now —routes/api.jsstill imports it until Task 6 rewrites the routes; Task 6 deletes it.[ ] Step 1: Write the failing test
Add to server/schemas/schemas.test.js:
js
import { buildPricingConfigSchema } from './pricingConfig.js';
import { getPlugin } from '../plugins/index.js';
describe('buildPricingConfigSchema', () => {
const mtgSchema = buildPricingConfigSchema(getPlugin('mtg'));
const pokemonSchema = buildPricingConfigSchema(getPlugin('pokemon'));
it('accepts a valid mtg partial update', () => {
const r = mtgSchema.safeParse({
globalModifier: { value: 1.2 },
minimumPrices: { byRarity: { rare: { etched: 2.5 } } }
});
expect(r.success).toBe(true);
});
it('rejects a pokemon rarity key under mtg', () => {
const r = mtgSchema.safeParse({
minimumPrices: { byRarity: { 'rare holo': { foil: 1 } } }
});
expect(r.success).toBe(false);
});
it('rejects a finish key that does not belong to the game', () => {
const r = pokemonSchema.safeParse({
minimumPrices: { byRarity: { rare: { etched: 1 } } }
});
expect(r.success).toBe(false);
});
it('accepts pokemon finishes for pokemon', () => {
const r = pokemonSchema.safeParse({
minimumPrices: { byRarity: { 'rare holo': { holofoil: 2, reverseHolofoil: 1.5 } } }
});
expect(r.success).toBe(true);
});
it('restricts sourcePriority to the plugin priceSources', () => {
expect(pokemonSchema.safeParse({ sourcePriority: ['cardkingdom'] }).success).toBe(false);
expect(pokemonSchema.safeParse({ sourcePriority: ['tcgplayer'] }).success).toBe(true);
expect(mtgSchema.safeParse({ sourcePriority: ['cardkingdom', 'tcgplayer'] }).success).toBe(true);
});
it('rejects negative minimums and out-of-range modifiers', () => {
expect(mtgSchema.safeParse({ minimumPrices: { byRarity: { rare: { foil: -1 } } } }).success).toBe(false);
expect(mtgSchema.safeParse({ globalModifier: { value: 5.0 } }).success).toBe(false);
});
});- [ ] Step 2: Run to verify failure
Run: npx vitest run server/schemas/schemas.test.js Expected: FAIL — buildPricingConfigSchema not exported.
- [ ] Step 3: Implement
In server/schemas/pricingConfig.js, keep globalModifierSchema, conditionVariantsSchema, roundingSchema — and leave rarityMinimumSchema, minimumPricesSchema, pricingConfigUpdateSchema, VALID_PRICE_SOURCES untouched for now (routes/api.js still imports pricingConfigUpdateSchema; Task 6 deletes all four with the route rewrite); add:
js
/**
* Build the per-game pricing-config update schema from a game plugin.
* Strict on rarity and finish keys: anything outside the plugin's declared
* vocabulary is a validation error, not silently stored.
*/
function buildPricingConfigSchema(plugin) {
const finishKeys = Object.keys(plugin.finishes);
const rarityKeys = Object.keys(plugin.rarities);
const finishMinimumsSchema = z
.object(Object.fromEntries(
finishKeys.map((f) => [f, z.number().min(0).optional()])
))
.strict();
const byRaritySchema = z
.object(Object.fromEntries(
rarityKeys.map((r) => [r, finishMinimumsSchema.optional()])
))
.strict();
const minimumPricesSchema = z
.object({
enabled: z.boolean().optional(),
global: z.number().min(0).optional(),
byRarity: byRaritySchema.optional(),
})
.optional();
return z.object({
useRawPlatformPricing: z.boolean().optional(),
sourcePriority: z
.array(z.enum([...plugin.priceSources]))
.nonempty('sourcePriority must not be empty')
.optional(),
preSalePlaceholderPrice: z.number().min(0).optional(),
globalModifier: globalModifierSchema,
conditionVariants: conditionVariantsSchema,
rounding: roundingSchema,
minimumPrices: minimumPricesSchema,
});
}Update module.exports to add buildPricingConfigSchema (keeping all existing exports), and update server/schemas/index.js to also re-export buildPricingConfigSchema. Port any existing schemas.test.js assertions that pinned pricingConfigUpdateSchema behavior (rewrite them against buildPricingConfigSchema(getPlugin('mtg')) — same accept/reject intent); the old schema and its dedicated tests are deleted in Task 6.
- [ ] Step 4: Run to verify pass
Run: npx vitest run server/schemas/schemas.test.js Expected: PASS
- [ ] Step 5: Commit
bash
git add server/schemas/
git commit -m "feat(pricing): per-game Zod schema factory keyed on plugin rarities/finishes"Task 6: Routes — GET/PUT /api/pricing-config?game=
Files:
- Modify:
server/routes/api.js(GET handler ~line 1689; PUT handler ~line 1770; imports ~line 28) - Modify:
server/services/pricingConfigService.js+ test (addbuildPricingConfigResponse)
Interfaces:
Consumes:
getGamePricingConfig/applyGamePricingConfigUpdate(Task 3),buildPricingConfigSchema(Task 5),isGameSupported/getPluginfromserver/plugins.Produces (HTTP):
GET /api/pricing-config?game=mtg|pokemon(defaultmtg) →{ game, rarities: [{id, name}], finishes: [{id, name}], sources: string[], config }—raritiessorted bysortOrder,finishesin declaration order,sources=plugin.priceSources,configfully resolved.PUT /api/pricing-config?game=with a partial-config body →{ success: true, message, game, config }(the merged config). 400 on unsupported game or schema violation.
Produces (service):
buildPricingConfigResponse(store, gameId)→ the GET response object (unit-testable without HTTP).[ ] Step 1: Write the failing test (extend
server/services/pricingConfigService.test.js)
js
import { buildPricingConfigResponse } from './pricingConfigService.js';
describe('buildPricingConfigResponse', () => {
it('returns config plus sorted rarity/finish metadata', () => {
const res = buildPricingConfigResponse(fakeStore({}), 'pokemon');
expect(res.game).toBe('pokemon');
expect(res.rarities[0]).toEqual({ id: 'common', name: 'Common' });
const ids = res.rarities.map((r) => r.id);
expect(ids.indexOf('common')).toBeLessThan(ids.indexOf('rare holo'));
expect(res.finishes.map((f) => f.id)).toContain('reverseHolofoil');
expect(res.sources).toEqual(['tcgplayer']);
expect(res.config.minimumPrices.byRarity.common.normal).toBe(0.25);
});
});- [ ] Step 2: Run to verify failure
Run: npx vitest run server/services/pricingConfigService.test.js Expected: FAIL — buildPricingConfigResponse not exported.
- [ ] Step 3: Implement the service function (in
pricingConfigService.js)
js
function buildPricingConfigResponse(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 })),
finishes: Object.entries(plugin.finishes)
.map(([id, def]) => ({ id, name: def.name })),
sources: [...plugin.priceSources],
config: getGamePricingConfig(store, gameId)
};
}Add buildPricingConfigResponse to the exports. Run the test file — PASS.
- [ ] Step 4: Rewrite the route handlers
In server/routes/api.js, replace the entire GET handler body (~lines 1689-1758) and the entire PUT handler (~lines 1770-1905) with:
js
router.get('/pricing-config', async (req, res) => {
try {
const game = req.query.game || 'mtg';
const { isGameSupported } = require('../plugins');
if (!isGameSupported(game)) {
return res.status(400).json({ error: `Unsupported game: ${game}` });
}
const { buildPricingConfigResponse } = require('../services/pricingConfigService');
res.json(buildPricingConfigResponse(req.store, game));
} catch (error) {
logger.error('Failed to fetch pricing config', { error: error.message });
res.status(500).json({ error: 'Failed to fetch pricing config' });
}
});
router.put('/pricing-config', async (req, res) => {
try {
const game = req.query.game || 'mtg';
const { isGameSupported, getPlugin } = require('../plugins');
if (!isGameSupported(game)) {
return res.status(400).json({ error: `Unsupported game: ${game}` });
}
// Schema depends on the game, so validation is inline rather than via
// the static validate() middleware.
const { buildPricingConfigSchema } = require('../schemas');
const parsed = buildPricingConfigSchema(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 { applyGamePricingConfigUpdate } = require('../services/pricingConfigService');
const config = applyGamePricingConfigUpdate(req.store, game, parsed.data);
await req.store.save();
logger.info('Updated pricing config for store', { shop: req.store.shop, game });
res.json({ success: true, message: 'Pricing configuration saved successfully', game, config });
} catch (error) {
logger.error('Failed to update pricing config', { error: error.message });
res.status(500).json({ error: 'Failed to update pricing config' });
}
});Also remove the now-unused pricingConfigUpdateSchema import (~line 28) and keep the doc comments above each route, updated to mention ?game=. Then delete the legacy schema pieces Task 5 left behind: rarityMinimumSchema, minimumPricesSchema, pricingConfigUpdateSchema, VALID_PRICE_SOURCES in server/schemas/pricingConfig.js, their re-exports in server/schemas/index.js, and any schemas.test.js tests that target pricingConfigUpdateSchema directly (their intent was ported in Task 5).
- [ ] Step 5: Lint + full suite
Run: npm run lint && npm test Expected: lint clean; failures only in files owned by Tasks 7-8 (note them, don't fix here).
- [ ] Step 6: Commit
bash
git add server/routes/api.js server/services/pricingConfigService.js server/services/pricingConfigService.test.js server/schemas/index.js
git commit -m "feat(pricing): game-aware GET/PUT /api/pricing-config with rarity/finish metadata"Task 7: Consumers — syncService + price-update path resolve per game
Files:
- Modify:
server/services/syncService.js(batch path ~line 215; preview path ~line 853) - Modify:
server/services/priceUpdateService.js(calculatePriceUpdatessignature ~line 400;_findRawPokemonPriceInTimeseries~line 66) - Modify:
server/queues/processors/priceUpdateProcessor.js(~line 140, ~line 233) - Modify:
server/services/syncService.test.js,server/queues/processors/priceUpdateProcessor.test.js(update fixtures/assertions)
Interfaces:
Consumes:
getGamePricingConfig(store, gameId)(Task 3).Produces:
PriceUpdateService.calculatePriceUpdates(variantMap, pricingConfigs, onProgress)— second param is now{ mtg: <config>, pokemon: <config> }(a map keyed by game id, not a single config). The MTG loop usespricingConfigs.mtg, the Pokemon looppricingConfigs.pokemon.[ ] Step 1: syncService — resolve per game
Both sites already have gameType in scope. At ~line 215 (syncSetDirect), replace:
js
const store = await Store.findOne({ shop: this.shop });
const pricingConfig = store?.pricingConfig || undefined;with:
js
const store = await Store.findOne({ shop: this.shop });
const { getGamePricingConfig } = require('./pricingConfigService');
const pricingConfig = getGamePricingConfig(store, gameType);At ~line 853 (previewSyncProducts), the same replacement (the variable there is also gameType). pricingConfig is now always defined, so the if (pricingConfig) debug-log guard keeps working; conditionConfig derivation (pricingConfig?.conditionVariants) is unchanged.
- [ ] Step 2: Write the failing priceUpdateService test
Add to server/queues/processors/priceUpdateProcessor.test.js (or the existing priceUpdateService coverage within it — follow where calculatePriceUpdates is currently exercised). New test:
js
it('prices each game with its own config', async () => {
// Arrange a variantMap with one MTG variant (rarity 'rare', finish 'Foil')
// and one Pokemon variant (rarity 'rare holo', finish 'holofoil'), with
// price caches returning rawPrice 0.10 for both (below all floors).
// MTG config floor: byRarity.rare.foil = 3.00; global 0.59
// Pokemon config floor: byRarity['rare holo'].holofoil = 7.00; global 0.59
const pricingConfigs = {
mtg: {
minimumPrices: { enabled: true, global: 0.59, byRarity: { rare: { foil: 3.00 } } }
},
pokemon: {
minimumPrices: { enabled: true, global: 0.59, byRarity: { 'rare holo': { holofoil: 7.00 } } }
}
};
const result = await service.calculatePriceUpdates(variantMap, pricingConfigs);
const mtgUpdate = result.updates.find((u) => u.sku === MTG_SKU);
const pokemonUpdate = result.updates.find((u) => u.sku === POKEMON_SKU);
expect(mtgUpdate.newPrice).toBe(3.00);
expect(pokemonUpdate.newPrice).toBe(7.00);
});(Adapt the arrange section to the file's existing mock scaffolding for variantMap, price caches, and product lookups — the test file already builds these for the current single-config tests; mirror that setup.)
- [ ] Step 3: Run to verify failure
Run: npx vitest run server/queues/processors/priceUpdateProcessor.test.js Expected: new test FAILS (single-config signature).
- [ ] Step 4: Implement priceUpdateService changes
calculatePriceUpdates(variantMap, pricingConfigs, onProgress = null)— rename the param; at the top of the method add a guard so old single-config callers in tests fail loudly:
js
if (!pricingConfigs || !pricingConfigs.mtg || !pricingConfigs.pokemon) {
throw new Error('calculatePriceUpdates requires per-game pricingConfigs { mtg, pokemon }');
}- In the MTG loop (~lines 585-632), replace every
pricingConfigreference withpricingConfigs.mtg(both thefindRawPriceInTimeseries(...)call and thecalculatePriceWithCondition(...)call). - In the Pokemon loop (~lines 634-680), replace every
pricingConfigreference withpricingConfigs.pokemon. _findRawPokemonPriceInTimeseries(~line 66): change
js
return { rawPrice: raw, priceType: key === 'normal' ? 'normal' : 'foil' };to:
js
return { rawPrice: raw, priceType: key };(matching Task 4's bucket-key passthrough so Pokemon per-finish minimums apply on the scheduled path too).
- In
priceUpdateProcessor.js(~line 140), replace:
js
const pricingConfig = store.pricingConfig;with:
js
const { getGamePricingConfig } = require('../../services/pricingConfigService');
const pricingConfigs = {
mtg: getGamePricingConfig(store, 'mtg'),
pokemon: getGamePricingConfig(store, 'pokemon')
};and the call site (~line 233) passes pricingConfigs.
- Update every existing test that calls
calculatePriceUpdates(x, config)with a single config to pass{ mtg: config, pokemon: config }— same expectations, new shape. Update any processor test mockingstore.pricingConfigsimilarly (the processor now resolves throughgetGamePricingConfig, which tolerates{}and legacy shapes, so most fixtures keep working — adjust assertions that pinned the old passthrough).
- [ ] Step 5: Run affected suites
Run: npx vitest run server/queues/processors/priceUpdateProcessor.test.js server/services/syncService.test.js Expected: PASS. If syncService.test.js mocks Store.findOne to return a store with an old flat pricingConfig, it now flows through the legacy fallback — expectations should hold; update any assertion that pinned the raw flat object being passed to applyPricing (it now receives the resolved config).
- [ ] Step 6: Commit
bash
git add server/services/syncService.js server/services/priceUpdateService.js server/queues/processors/
git commit -m "feat(pricing): sync + scheduled price updates resolve per-game configs"Task 8: Migration script
Files:
- Create:
server/scripts/migrations/migratePerGamePricingConfig.js - Create:
server/scripts/migrations/migratePerGamePricingConfig.test.js - Modify:
package.json(two scripts)
Interfaces:
Consumes:
isLegacyFlatConfig,convertLegacyConfig(Task 3).Produces: exported
migrateStorePricingConfig(pricingConfig)→{ changed: boolean, next: object }pure function (unit-tested), plus a CLI entry that applies it to every store.npm run migrate:pricing-config/npm run migrate:pricing-config:dry-run.[ ] Step 1: Write the failing test
js
// server/scripts/migrations/migratePerGamePricingConfig.test.js
import { describe, it, expect } from 'vitest';
import { migrateStorePricingConfig } from './migratePerGamePricingConfig.js';
const LEGACY_FLAT = {
globalModifier: { enabled: true, value: 1.25, threshold: 30 },
minimumPrices: { enabled: true, global: 0.75, rare: { normal: 2, foil: 2 }, mythic: { normal: 1.5, foil: 1.5 } }
};
describe('migrateStorePricingConfig', () => {
it('moves a legacy flat config under mtg in the new shape', () => {
const { changed, next } = migrateStorePricingConfig(LEGACY_FLAT);
expect(changed).toBe(true);
expect(next.mtg.globalModifier.value).toBe(1.25);
expect(next.mtg.minimumPrices.byRarity.rare.normal).toBe(2);
expect(next.mtg.minimumPrices.rare).toBeUndefined();
expect(next.globalModifier).toBeUndefined();
expect(next.pokemon).toBeUndefined(); // other games fill lazily from defaults
});
it('is idempotent: already-migrated and empty configs are untouched', () => {
expect(migrateStorePricingConfig({ mtg: { rounding: { enabled: true } } }).changed).toBe(false);
expect(migrateStorePricingConfig({}).changed).toBe(false);
expect(migrateStorePricingConfig(undefined).changed).toBe(false);
});
});- [ ] Step 2: Run to verify failure
Run: npx vitest run server/scripts/migrations/migratePerGamePricingConfig.test.js Expected: FAIL — module doesn't exist.
- [ ] Step 3: Implement
js
// server/scripts/migrations/migratePerGamePricingConfig.js
/**
* Migration: move the pre-split flat Store.pricingConfig under
* pricingConfig.mtg in the new byRarity shape. Other games are left unset
* (they resolve lazily from plugin defaults via pricingConfigService).
*
* Run with:
* node server/scripts/migrations/migratePerGamePricingConfig.js [--dry-run]
*
* Idempotent: already-migrated stores are skipped.
*/
require('dotenv').config();
const mongoose = require('mongoose');
const {
isLegacyFlatConfig,
convertLegacyConfig
} = require('../../services/pricingConfigService');
function migrateStorePricingConfig(pricingConfig) {
if (!isLegacyFlatConfig(pricingConfig)) {
return { changed: false, next: pricingConfig };
}
return { changed: true, next: { mtg: convertLegacyConfig(pricingConfig) } };
}
async function run() {
const dryRun = process.argv.includes('--dry-run');
const Store = require('../../models/Store');
const logger = require('../../utils/logger');
const mongoUri = process.env.MONGODB_URI;
if (!mongoUri) throw new Error('MONGODB_URI not configured');
await mongoose.connect(mongoUri);
logger.info('Connected to MongoDB', { dryRun });
const stores = await Store.find({}, { shop: 1, pricingConfig: 1 });
let migrated = 0;
let skipped = 0;
for (const store of stores) {
const pc = store.pricingConfig && store.pricingConfig.toObject
? store.pricingConfig.toObject()
: store.pricingConfig;
const { changed, next } = migrateStorePricingConfig(pc);
if (!changed) { skipped++; continue; }
logger.info(`${dryRun ? '[dry-run] Would migrate' : 'Migrating'} pricingConfig`, { shop: store.shop });
if (!dryRun) {
store.pricingConfig = next;
store.markModified('pricingConfig');
await store.save();
}
migrated++;
}
logger.info('Migration complete', { migrated, skipped, dryRun });
await mongoose.disconnect();
}
module.exports = { migrateStorePricingConfig };
if (require.main === module) {
run().then(() => process.exit(0)).catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
}In package.json scripts (next to migrate:tokens):
json
"migrate:pricing-config": "node server/scripts/migrations/migratePerGamePricingConfig.js",
"migrate:pricing-config:dry-run": "node server/scripts/migrations/migratePerGamePricingConfig.js --dry-run",- [ ] Step 4: Run to verify pass
Run: npx vitest run server/scripts/migrations/migratePerGamePricingConfig.test.js Expected: PASS
- [ ] Step 5: Commit
bash
git add server/scripts/migrations/ package.json
git commit -m "feat(pricing): idempotent migration moving flat pricingConfig under mtg"Task 9: Client — extract PricingConfigPanel with the dynamic rarity×finish table
Files:
- Create:
client/src/components/PricingConfigPanel.jsx - Create:
client/src/components/PricingConfigPanel.test.jsx - Modify:
client/src/pages/Home.jsx(SyncSettingsView~lines 350-1079;SyncTabrender at ~line 342)
Interfaces:
Consumes:
GET /api/pricing-config?game=response{ game, rarities, finishes, config };PUT /api/pricing-config?game=with a config body (Task 6).Produces:
<PricingConfigPanel game="mtg" />— self-contained pricing settings card (load, edit, save). This is the component PR #306's/catalog/:game/settings/pricingroute will mount directly; keep it free ofHome.jsxcoupling (no props other thangame).- Exported pure helper
buildRarityRows(rarities, finishes, byRarity)→[{ rarityId, rarityName, cells: [{ finishId, finishName, value }] }](unit-tested).
[ ] Step 1: Write the failing helper test
jsx
// client/src/components/PricingConfigPanel.test.jsx
import { describe, it, expect } from 'vitest';
import { buildRarityRows } from './PricingConfigPanel';
describe('buildRarityRows', () => {
const rarities = [
{ id: 'common', name: 'Common' },
{ id: 'rare holo', name: 'Rare Holo' }
];
const finishes = [
{ id: 'normal', name: 'Normal' },
{ id: 'holofoil', name: 'Holofoil' }
];
it('builds one row per rarity with one cell per finish', () => {
const rows = buildRarityRows(rarities, finishes, {
common: { normal: 0.25, holofoil: 0.5 },
'rare holo': { normal: 1.0 }
});
expect(rows).toHaveLength(2);
expect(rows[0]).toEqual({
rarityId: 'common',
rarityName: 'Common',
cells: [
{ finishId: 'normal', finishName: 'Normal', value: 0.25 },
{ finishId: 'holofoil', finishName: 'Holofoil', value: 0.5 }
]
});
});
it('missing values come back as empty string for controlled inputs', () => {
const rows = buildRarityRows(rarities, finishes, {});
expect(rows[0].cells[0].value).toBe('');
});
});- [ ] Step 2: Run to verify failure
Run: npm run test:client -- --run client/src/components/PricingConfigPanel.test.jsx Expected: FAIL — module doesn't exist.
- [ ] Step 3: Create the component
client/src/components/PricingConfigPanel.jsx structure:
- Move from
Home.jsxSyncSettingsViewinto the new file:- the
pricingConfigstate block and its sibling state (pricingLoading,pricingSaving,pricingSaveSuccess,pricingSaveError) — lines ~368-410 loadPricingConfig,savePricingConfig,updatePricingConfig— lines ~418-530- the entire "Pricing Configuration"
<Card>JSX — lines ~672-1060 - the imports those pieces need (
api, retroui components, icons,useStore)
- the
- Component signature and game-aware load/save:
jsx
export function buildRarityRows(rarities, finishes, byRarity) {
return (rarities || []).map(({ id: rarityId, name: rarityName }) => ({
rarityId,
rarityName,
cells: (finishes || []).map(({ id: finishId, name: finishName }) => ({
finishId,
finishName,
value: byRarity?.[rarityId]?.[finishId] ?? ''
}))
}));
}
function PricingConfigPanel({ game = 'mtg' }) {
const { activeShop } = useStore();
const [pricingConfig, setPricingConfig] = useState(null);
const [meta, setMeta] = useState({ rarities: [], finishes: [], sources: [] });
// ...loading/saving/success/error state moved from SyncSettingsView...
useEffect(() => {
if (!activeShop) return;
loadPricingConfig();
}, [activeShop, game]);
const loadPricingConfig = async () => {
try {
setPricingLoading(true);
const { data } = await api.get('/pricing-config', { params: { game } });
setPricingConfig(data.config);
setMeta({ rarities: data.rarities, finishes: data.finishes, sources: data.sources });
} catch (error) {
console.error('Failed to load pricing config:', error);
} finally {
setPricingLoading(false);
}
};
const savePricingConfig = async () => {
// same success/error handling as before, but:
await api.put('/pricing-config', pricingConfig, { params: { game } });
};
// updatePricingConfig(path, value) moves over unchanged.
// render: null-guard until pricingConfig loads, then the moved JSX.
}
export default PricingConfigPanel;- Replace the hardcoded minimum-price inputs (the moved JSX block that had the four Rare/Mythic normal/foil inputs plus the global minimum, originally
Home.jsx~lines 941-1005): keep the global-minimum input, then render the dynamic table:
jsx
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr>
<th className="text-left p-2">Rarity</th>
{meta.finishes.map((f) => (
<th key={f.id} className="text-left p-2">{f.name}</th>
))}
</tr>
</thead>
<tbody>
{buildRarityRows(meta.rarities, meta.finishes, pricingConfig.minimumPrices?.byRarity).map((row) => (
<tr key={row.rarityId} className="border-t">
<td className="p-2 font-medium">{row.rarityName}</td>
{row.cells.map((cell) => (
<td key={cell.finishId} className="p-2">
<Input
type="number"
step="0.01"
min="0"
className="w-24"
value={cell.value}
onChange={(e) => updatePricingConfig(
`minimumPrices.byRarity.${row.rarityId}.${cell.finishId}`,
e.target.value === '' ? undefined : parseFloat(e.target.value)
)}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>Check updatePricingConfig's path-splitting: rarity ids contain spaces ('rare holo') but not dots, so path.split('.') remains safe — verify the moved implementation splits on '.' only and keep it.
Source-priority buttons: the moved JSX has two hardcoded TCGplayer/CardKingdom buttons. Do not hardcode by game — the GET response's
sourcesarray (Task 6) is the allowed set. Render one button per entry inmeta.sources(highlighting the currentsourcePriority[0], clicking an entry moves it to the front ofsourcePriority), and hide the whole priority section whenmeta.sources.length < 2(Pokemon has only TCGplayer, so there is nothing to prioritize).In
Home.jsx:SyncSettingsView()→SyncSettingsView({ game = 'mtg' }); delete the moved pricing state/handlers/JSX; where the pricing card was rendered, mount<PricingConfigPanel game={game} />.- Line ~342:
{view === 'schedule' && <SyncSettingsView game={game} />}(SyncTab already hasgamein scope). - Remove now-unused imports from
Home.jsx.
- [ ] Step 4: Run client tests + build
Run: npm run test:client -- --run then npm run build Expected: helper tests PASS; existing Home-related client tests PASS; build clean.
- [ ] Step 5: Manual smoke check (dev)
Run: npm run dev:no-worker, open the MTG sync page → Settings view: pricing card loads with the 6-rarity × 3-finish MTG table pre-filled; switch to /sync/pokemon: table shows Pokemon rarities × 5 finishes; CardKingdom button absent. Save on one game, reload the other — values don't bleed across.
- [ ] Step 6: Commit
bash
git add client/src/components/PricingConfigPanel.jsx client/src/components/PricingConfigPanel.test.jsx client/src/pages/Home.jsx server/services/pricingConfigService.js server/services/pricingConfigService.test.js
git commit -m "feat(pricing): standalone game-aware PricingConfigPanel with dynamic rarity/finish table"Task 10: Full verification sweep
Files:
Modify: whatever the sweep surfaces (stragglers referencing the old config shape)
Modify:
CLAUDE.md/server/CLAUDE.md(pricing pipeline notes mention per-game configs)[ ] Step 1: Full server suite with coverage
Run: npm run test:coverage Expected: PASS, 70%+ on modified files. Fix any straggler test still building the old minimumPrices.rare/mythic shape or passing a single config where a per-game map is now required.
- [ ] Step 2: Client tests + lint + build
Run: npm run test:client -- --run && npm run lint && npm run build Expected: all clean.
- [ ] Step 3: Grep for dead references
Run: grep -rn "pricingConfigUpdateSchema\|minimumPrices\.rare\|minimumPrices\.mythic" server client --include="*.js" --include="*.jsx" Expected: no hits outside historical docs. Remove any found.
- [ ] Step 4: Update docs
In root CLAUDE.md (Multi-Stage Pricing bullet) and server/CLAUDE.md (Multi-Stage Pricing section): note that pricing configs are per-game (Store.pricingConfig[gameId], resolved via services/pricingConfigService.js), minimums are byRarity[rarity][finish] keyed on plugin vocabularies, and the migration script npm run migrate:pricing-config moves legacy flat configs.
- [ ] Step 5: Commit
bash
git add -A
git commit -m "chore(pricing): verification sweep + docs for per-game pricing"Post-plan reminders (not tasks)
- Run
npm run migrate:pricing-config:dry-runagainst production after deploy, then the real run. The runtime legacy fallback makes order non-critical, but the migration should still happen promptly. - The transparency spec (
2026-07-11-pricing-transparency-design.md) builds ongetGamePricingConfig— do not start it before this plan lands. - PR #306 (nav restructure) will mount
PricingConfigPanelat/catalog/:game/settings/pricing; keep the component prop surface to justgame.
